From 5e66d99c852ea67a88b650c03b0e8d55e83eccde Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Sat, 5 Dec 2020 06:07:18 +0530 Subject: Refactor package structure (#1233) * idea: default test runner to Gradle * Kick off package structure revamp * Reparent all classes under dev.msfjarvis.aps Signed-off-by: Harsh Shandilya --- .../java/com/zeapo/pwdstore/MigrationsTest.kt | 109 ---- .../com/zeapo/pwdstore/StrictDomainRegexTest.kt | 49 -- .../pwdstore/model/PasswordEntryAndroidTest.kt | 115 ---- .../com/zeapo/pwdstore/utils/UriTotpFinderTest.kt | 45 -- .../aps/data/password/PasswordEntryAndroidTest.kt | 115 ++++ .../msfjarvis/aps/util/settings/MigrationsTest.kt | 107 ++++ .../msfjarvis/aps/util/totp/UriTotpFinderTest.kt | 45 ++ .../aps/util/viewmodel/StrictDomainRegexTest.kt | 49 ++ .../autofill/oreo/ui/AutofillSmsActivity.kt | 27 - .../aps/autofill/oreo/ui/AutofillSmsActivity.kt | 27 + app/src/main/AndroidManifest.xml | 46 +- .../main/java/com/zeapo/pwdstore/Application.kt | 65 -- .../java/com/zeapo/pwdstore/ClipboardService.kt | 182 ------ .../main/java/com/zeapo/pwdstore/LaunchActivity.kt | 62 -- app/src/main/java/com/zeapo/pwdstore/Migrations.kt | 117 ---- .../com/zeapo/pwdstore/PasswordExportService.kt | 157 ----- .../java/com/zeapo/pwdstore/PasswordFragment.kt | 342 ---------- .../main/java/com/zeapo/pwdstore/PasswordStore.kt | 692 -------------------- .../pwdstore/SearchableRepositoryViewModel.kt | 474 -------------- .../com/zeapo/pwdstore/SelectFolderActivity.kt | 60 -- .../com/zeapo/pwdstore/SelectFolderFragment.kt | 77 --- .../main/java/com/zeapo/pwdstore/UserPreference.kt | 673 -------------------- .../autofill/oreo/Api30AutofillResponseBuilder.kt | 189 ------ .../pwdstore/autofill/oreo/AutofillMatcher.kt | 193 ------ .../pwdstore/autofill/oreo/AutofillPreferences.kt | 139 ---- .../autofill/oreo/AutofillResponseBuilder.kt | 203 ------ .../pwdstore/autofill/oreo/AutofillViewUtils.kt | 113 ---- .../pwdstore/autofill/oreo/OreoAutofillService.kt | 142 ----- .../autofill/oreo/ui/AutofillDecryptActivity.kt | 245 -------- .../autofill/oreo/ui/AutofillFilterActivity.kt | 220 ------- .../oreo/ui/AutofillPublisherChangedActivity.kt | 114 ---- .../autofill/oreo/ui/AutofillSaveActivity.kt | 149 ----- .../autofill/oreo/ui/PasswordViewHolder.kt | 16 - .../com/zeapo/pwdstore/crypto/BasePgpActivity.kt | 310 --------- .../com/zeapo/pwdstore/crypto/DecryptActivity.kt | 260 -------- .../com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt | 76 --- .../pwdstore/crypto/PasswordCreationActivity.kt | 513 --------------- .../java/com/zeapo/pwdstore/git/BaseGitActivity.kt | 144 ----- .../java/com/zeapo/pwdstore/git/ErrorMessages.kt | 67 -- .../com/zeapo/pwdstore/git/GitCommandExecutor.kt | 117 ---- .../com/zeapo/pwdstore/git/GitConfigActivity.kt | 152 ----- .../zeapo/pwdstore/git/GitServerConfigActivity.kt | 265 -------- .../com/zeapo/pwdstore/git/config/GitSettings.kt | 182 ------ .../java/com/zeapo/pwdstore/git/log/GitCommit.kt | 18 - .../com/zeapo/pwdstore/git/log/GitLogActivity.kt | 49 -- .../com/zeapo/pwdstore/git/log/GitLogAdapter.kt | 56 -- .../java/com/zeapo/pwdstore/git/log/GitLogModel.kt | 55 -- .../pwdstore/git/operation/BreakOutOfDetached.kt | 36 -- .../zeapo/pwdstore/git/operation/CloneOperation.kt | 22 - .../pwdstore/git/operation/CredentialFinder.kt | 98 --- .../zeapo/pwdstore/git/operation/GitOperation.kt | 216 ------- .../zeapo/pwdstore/git/operation/PullOperation.kt | 31 - .../zeapo/pwdstore/git/operation/PushOperation.kt | 15 - .../git/operation/ResetToRemoteOperation.kt | 23 - .../zeapo/pwdstore/git/operation/SyncOperation.kt | 23 - .../git/sshj/ContinuationContainerActivity.kt | 37 -- .../pwdstore/git/sshj/OpenKeychainKeyProvider.kt | 191 ------ .../sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt | 93 --- .../java/com/zeapo/pwdstore/git/sshj/SshKey.kt | 336 ---------- .../java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt | 276 -------- .../zeapo/pwdstore/git/sshj/SshjSessionFactory.kt | 196 ------ .../java/com/zeapo/pwdstore/model/PasswordEntry.kt | 139 ---- .../com/zeapo/pwdstore/pwgen/PasswordGenerator.kt | 139 ---- .../zeapo/pwdstore/pwgen/RandomNumberGenerator.kt | 33 - .../pwdstore/pwgen/RandomPasswordGenerator.kt | 45 -- .../pwdstore/pwgen/RandomPhonemesGenerator.kt | 169 ----- .../java/com/zeapo/pwdstore/pwgenxkpwd/CapsType.kt | 9 - .../zeapo/pwdstore/pwgenxkpwd/PasswordBuilder.kt | 142 ----- .../zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt | 40 -- .../zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt | 38 -- .../zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt | 164 ----- .../com/zeapo/pwdstore/ui/OnOffItemAnimator.kt | 71 --- .../ui/adapters/PasswordItemRecyclerAdapter.kt | 82 --- .../zeapo/pwdstore/ui/dialogs/BasicBottomSheet.kt | 161 ----- .../ui/dialogs/FolderCreationDialogFragment.kt | 106 ---- .../pwdstore/ui/dialogs/ItemCreationBottomSheet.kt | 77 --- .../ui/dialogs/PasswordGeneratorDialogFragment.kt | 101 --- .../dialogs/XkPasswordGeneratorDialogFragment.kt | 131 ---- .../ui/onboarding/activity/OnboardingActivity.kt | 26 - .../ui/onboarding/fragments/CloneFragment.kt | 59 -- .../onboarding/fragments/KeySelectionFragment.kt | 66 -- .../onboarding/fragments/RepoLocationFragment.kt | 192 ------ .../ui/onboarding/fragments/WelcomeFragment.kt | 30 - .../pwdstore/ui/proxy/ProxySelectorActivity.kt | 75 --- .../com/zeapo/pwdstore/utils/AndroidExtensions.kt | 178 ------ .../zeapo/pwdstore/utils/BiometricAuthenticator.kt | 78 --- .../java/com/zeapo/pwdstore/utils/Extensions.kt | 89 --- .../com/zeapo/pwdstore/utils/FragmentExtensions.kt | 36 -- .../pwdstore/utils/FragmentViewBindingDelegate.kt | 65 -- app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt | 73 --- .../java/com/zeapo/pwdstore/utils/PasswordItem.kt | 86 --- .../com/zeapo/pwdstore/utils/PasswordRepository.kt | 238 ------- .../com/zeapo/pwdstore/utils/PasswordSortOrder.kt | 46 -- .../com/zeapo/pwdstore/utils/PreferenceKeys.kt | 85 --- .../java/com/zeapo/pwdstore/utils/ProxyUtils.kt | 66 -- .../java/com/zeapo/pwdstore/utils/TotpFinder.kt | 32 - .../java/com/zeapo/pwdstore/utils/UriTotpFinder.kt | 66 -- app/src/main/java/dev/msfjarvis/aps/Application.kt | 66 ++ .../msfjarvis/aps/data/password/PasswordEntry.kt | 139 ++++ .../msfjarvis/aps/data/password/PasswordItem.kt | 86 +++ .../msfjarvis/aps/data/repo/PasswordRepository.kt | 243 +++++++ .../aps/ui/adapters/PasswordItemRecyclerAdapter.kt | 83 +++ .../aps/ui/autofill/AutofillDecryptActivity.kt | 245 ++++++++ .../aps/ui/autofill/AutofillFilterActivity.kt | 220 +++++++ .../autofill/AutofillPublisherChangedActivity.kt | 114 ++++ .../aps/ui/autofill/AutofillSaveActivity.kt | 149 +++++ .../aps/ui/autofill/PasswordViewHolder.kt | 16 + .../dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt | 310 +++++++++ .../dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt | 260 ++++++++ .../msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt | 76 +++ .../aps/ui/crypto/PasswordCreationActivity.kt | 513 +++++++++++++++ .../msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt | 161 +++++ .../aps/ui/dialogs/FolderCreationDialogFragment.kt | 106 ++++ .../aps/ui/dialogs/ItemCreationBottomSheet.kt | 77 +++ .../ui/dialogs/PasswordGeneratorDialogFragment.kt | 101 +++ .../dialogs/XkPasswordGeneratorDialogFragment.kt | 131 ++++ .../aps/ui/folderselect/SelectFolderActivity.kt | 63 ++ .../aps/ui/folderselect/SelectFolderFragment.kt | 81 +++ .../msfjarvis/aps/ui/git/base/BaseGitActivity.kt | 146 +++++ .../aps/ui/git/config/GitConfigActivity.kt | 153 +++++ .../aps/ui/git/config/GitServerConfigActivity.kt | 267 ++++++++ .../dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt | 49 ++ .../dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt | 58 ++ .../dev/msfjarvis/aps/ui/main/LaunchActivity.kt | 63 ++ .../ui/onboarding/activity/OnboardingActivity.kt | 26 + .../aps/ui/onboarding/fragments/CloneFragment.kt | 59 ++ .../onboarding/fragments/KeySelectionFragment.kt | 66 ++ .../onboarding/fragments/RepoLocationFragment.kt | 192 ++++++ .../aps/ui/onboarding/fragments/WelcomeFragment.kt | 30 + .../msfjarvis/aps/ui/passwords/PasswordFragment.kt | 344 ++++++++++ .../msfjarvis/aps/ui/passwords/PasswordStore.kt | 697 +++++++++++++++++++++ .../aps/ui/proxy/ProxySelectorActivity.kt | 75 +++ .../msfjarvis/aps/ui/settings/UserPreference.kt | 676 ++++++++++++++++++++ .../aps/ui/sshkeygen/ShowSshKeyFragment.kt | 38 ++ .../aps/ui/sshkeygen/SshKeyGenActivity.kt | 164 +++++ .../dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt | 71 +++ .../aps/util/auth/BiometricAuthenticator.kt | 78 +++ .../util/autofill/Api30AutofillResponseBuilder.kt | 189 ++++++ .../msfjarvis/aps/util/autofill/AutofillMatcher.kt | 193 ++++++ .../aps/util/autofill/AutofillPreferences.kt | 140 +++++ .../aps/util/autofill/AutofillResponseBuilder.kt | 205 ++++++ .../aps/util/autofill/AutofillViewUtils.kt | 113 ++++ .../aps/util/extensions/AndroidExtensions.kt | 179 ++++++ .../msfjarvis/aps/util/extensions/Extensions.kt | 90 +++ .../aps/util/extensions/FragmentExtensions.kt | 36 ++ .../util/extensions/FragmentViewBindingDelegate.kt | 65 ++ .../dev/msfjarvis/aps/util/git/ErrorMessages.kt | 67 ++ .../msfjarvis/aps/util/git/GitCommandExecutor.kt | 117 ++++ .../java/dev/msfjarvis/aps/util/git/GitCommit.kt | 18 + .../java/dev/msfjarvis/aps/util/git/GitLogModel.kt | 55 ++ .../aps/util/git/operation/BreakOutOfDetached.kt | 36 ++ .../aps/util/git/operation/CloneOperation.kt | 22 + .../aps/util/git/operation/CredentialFinder.kt | 98 +++ .../aps/util/git/operation/GitOperation.kt | 216 +++++++ .../aps/util/git/operation/PullOperation.kt | 31 + .../aps/util/git/operation/PushOperation.kt | 15 + .../util/git/operation/ResetToRemoteOperation.kt | 23 + .../aps/util/git/operation/SyncOperation.kt | 23 + .../util/git/sshj/ContinuationContainerActivity.kt | 37 ++ .../aps/util/git/sshj/OpenKeychainKeyProvider.kt | 191 ++++++ .../sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt | 93 +++ .../java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt | 336 ++++++++++ .../dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt | 275 ++++++++ .../aps/util/git/sshj/SshjSessionFactory.kt | 197 ++++++ .../dev/msfjarvis/aps/util/proxy/ProxyUtils.kt | 66 ++ .../msfjarvis/aps/util/pwgen/PasswordGenerator.kt | 139 ++++ .../aps/util/pwgen/RandomNumberGenerator.kt | 33 + .../aps/util/pwgen/RandomPasswordGenerator.kt | 45 ++ .../aps/util/pwgen/RandomPhonemesGenerator.kt | 169 +++++ .../dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt | 9 + .../aps/util/pwgenxkpwd/PasswordBuilder.kt | 142 +++++ .../aps/util/pwgenxkpwd/XkpwdDictionary.kt | 40 ++ .../aps/util/services/ClipboardService.kt | 183 ++++++ .../aps/util/services/OreoAutofillService.kt | 145 +++++ .../aps/util/services/PasswordExportService.kt | 158 +++++ .../dev/msfjarvis/aps/util/settings/GitSettings.kt | 181 ++++++ .../dev/msfjarvis/aps/util/settings/Migrations.kt | 114 ++++ .../aps/util/settings/PasswordSortOrder.kt | 49 ++ .../msfjarvis/aps/util/settings/PreferenceKeys.kt | 85 +++ .../main/java/dev/msfjarvis/aps/util/totp/Otp.kt | 73 +++ .../java/dev/msfjarvis/aps/util/totp/TotpFinder.kt | 32 + .../dev/msfjarvis/aps/util/totp/UriTotpFinder.kt | 66 ++ .../viewmodel/SearchableRepositoryViewModel.kt | 474 ++++++++++++++ app/src/main/res/layout/activity_git_clone.xml | 2 +- app/src/main/res/layout/activity_git_config.xml | 2 +- app/src/main/res/layout/activity_git_log.xml | 2 +- app/src/main/res/layout/activity_onboarding.xml | 2 +- .../res/layout/activity_oreo_autofill_filter.xml | 2 +- .../activity_oreo_autofill_publisher_changed.xml | 2 +- app/src/main/res/layout/decrypt_layout.xml | 2 +- .../main/res/layout/password_creation_activity.xml | 2 +- app/src/main/res/layout/password_recycler_view.xml | 2 +- app/src/main/res/menu/pgp_handler.xml | 2 +- app/src/main/res/menu/pgp_handler_new_password.xml | 2 +- .../main/res/menu/pgp_handler_select_folder.xml | 2 +- .../autofill/oreo/ui/AutofillSmsActivity.kt | 160 ----- .../aps/autofill/oreo/ui/AutofillSmsActivity.kt | 160 +++++ .../res/layout/activity_oreo_autofill_sms.xml | 2 +- .../com/zeapo/pwdstore/model/PasswordEntryTest.kt | 153 ----- .../test/java/com/zeapo/pwdstore/utils/OtpTest.kt | 56 -- .../aps/data/password/PasswordEntryTest.kt | 153 +++++ .../java/dev/msfjarvis/aps/util/totp/OtpTest.kt | 56 ++ 202 files changed, 12503 insertions(+), 12464 deletions(-) delete mode 100644 app/src/androidTest/java/com/zeapo/pwdstore/MigrationsTest.kt delete mode 100644 app/src/androidTest/java/com/zeapo/pwdstore/StrictDomainRegexTest.kt delete mode 100644 app/src/androidTest/java/com/zeapo/pwdstore/model/PasswordEntryAndroidTest.kt delete mode 100644 app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt create mode 100644 app/src/androidTest/java/dev/msfjarvis/aps/data/password/PasswordEntryAndroidTest.kt create mode 100644 app/src/androidTest/java/dev/msfjarvis/aps/util/settings/MigrationsTest.kt create mode 100644 app/src/androidTest/java/dev/msfjarvis/aps/util/totp/UriTotpFinderTest.kt create mode 100644 app/src/androidTest/java/dev/msfjarvis/aps/util/viewmodel/StrictDomainRegexTest.kt delete mode 100644 app/src/free/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt create mode 100644 app/src/free/java/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/Application.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/Migrations.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/PasswordExportService.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/SelectFolderActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/UserPreference.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Api30AutofillResponseBuilder.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/log/GitCommit.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/log/GitLogActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/log/GitLogAdapter.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/log/GitLogModel.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/operation/SyncOperation.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/sshj/ContinuationContainerActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/pwgen/RandomNumberGenerator.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/pwgen/RandomPasswordGenerator.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/pwgen/RandomPhonemesGenerator.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/CapsType.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/PasswordBuilder.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/OnOffItemAnimator.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/dialogs/BasicBottomSheet.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/dialogs/ItemCreationBottomSheet.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/dialogs/XkPasswordGeneratorDialogFragment.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/onboarding/activity/OnboardingActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/CloneFragment.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/KeySelectionFragment.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/RepoLocationFragment.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/WelcomeFragment.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/ui/proxy/ProxySelectorActivity.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/AndroidExtensions.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/FragmentExtensions.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/FragmentViewBindingDelegate.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/PasswordItem.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/PasswordSortOrder.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/ProxyUtils.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt delete mode 100644 app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/Application.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/settings/UserPreference.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt create mode 100644 app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt delete mode 100644 app/src/nonFree/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt create mode 100644 app/src/nonFree/java/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt delete mode 100644 app/src/test/java/com/zeapo/pwdstore/model/PasswordEntryTest.kt delete mode 100644 app/src/test/java/com/zeapo/pwdstore/utils/OtpTest.kt create mode 100644 app/src/test/java/dev/msfjarvis/aps/data/password/PasswordEntryTest.kt create mode 100644 app/src/test/java/dev/msfjarvis/aps/util/totp/OtpTest.kt (limited to 'app/src') diff --git a/app/src/androidTest/java/com/zeapo/pwdstore/MigrationsTest.kt b/app/src/androidTest/java/com/zeapo/pwdstore/MigrationsTest.kt deleted file mode 100644 index 5ba1b307..00000000 --- a/app/src/androidTest/java/com/zeapo/pwdstore/MigrationsTest.kt +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -@file:Suppress("DEPRECATION") - -package com.zeapo.pwdstore - -import android.content.Context -import androidx.core.content.edit -import com.zeapo.pwdstore.git.config.AuthMode -import com.zeapo.pwdstore.git.config.Protocol -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class MigrationsTest { - - private fun checkOldKeysAreRemoved(context: Context) = with(context.sharedPrefs) { - assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT)) - assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME)) - assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER)) - assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION)) - assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL)) - } - - @Test - fun verifySshWithCustomPortMigration() { - val context = Application.instance.applicationContext - context.sharedPrefs.edit { - clear() - putString(PreferenceKeys.GIT_REMOTE_PORT, "2200") - putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") - putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo") - putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102") - putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref) - putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.Password.pref) - } - runMigrations(context) - checkOldKeysAreRemoved(context) - assertEquals( - context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL), - "ssh://msfjarvis@192.168.0.102:2200/mnt/disk3/pass-repo" - ) - } - - @Test - fun verifySshWithDefaultPortMigration() { - val context = Application.instance.applicationContext - context.sharedPrefs.edit { - clear() - putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") - putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo") - putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102") - putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref) - putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.SshKey.pref) - } - runMigrations(context) - checkOldKeysAreRemoved(context) - assertEquals( - context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL), - "msfjarvis@192.168.0.102:/mnt/disk3/pass-repo" - ) - } - - @Test - fun verifyHttpsWithGitHubMigration() { - val context = Application.instance.applicationContext - context.sharedPrefs.edit { - clear() - putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") - putString(PreferenceKeys.GIT_REMOTE_LOCATION, "Android-Password-Store/pass-test") - putString(PreferenceKeys.GIT_REMOTE_SERVER, "github.com") - putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Https.pref) - putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.None.pref) - } - runMigrations(context) - checkOldKeysAreRemoved(context) - assertEquals( - context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL), - "https://github.com/Android-Password-Store/pass-test" - ) - } - - @Test - fun verifyHiddenFoldersMigrationIfDisabled() { - val context = Application.instance.applicationContext - context.sharedPrefs.edit { clear() } - runMigrations(context) - assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true)) - assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)) - } - - @Test - fun verifyHiddenFoldersMigrationIfEnabled() { - val context = Application.instance.applicationContext - context.sharedPrefs.edit { - clear() - putBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true) - } - runMigrations(context) - assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)) - assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)) - } -} diff --git a/app/src/androidTest/java/com/zeapo/pwdstore/StrictDomainRegexTest.kt b/app/src/androidTest/java/com/zeapo/pwdstore/StrictDomainRegexTest.kt deleted file mode 100644 index 2631cb41..00000000 --- a/app/src/androidTest/java/com/zeapo/pwdstore/StrictDomainRegexTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue -import org.junit.Test - -private infix fun String.matchedForDomain(domain: String) = - SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true - -class StrictDomainRegexTest { - - @Test fun acceptsLiteralDomain() { - assertTrue("work/example.org/john.doe@example.org.gpg" matchedForDomain "example.org") - assertTrue("example.org/john.doe@example.org.gpg" matchedForDomain "example.org") - assertTrue("example.org.gpg" matchedForDomain "example.org") - } - - @Test fun acceptsSubdomains() { - assertTrue("work/www.example.org/john.doe@example.org.gpg" matchedForDomain "example.org") - assertTrue("www2.example.org/john.doe@example.org.gpg" matchedForDomain "example.org") - assertTrue("www.login.example.org.gpg" matchedForDomain "example.org") - } - - @Test fun rejectsPhishingAttempts() { - assertFalse("example.org.gpg" matchedForDomain "xample.org") - assertFalse("login.example.org.gpg" matchedForDomain "xample.org") - assertFalse("example.org/john.doe@exmple.org.gpg" matchedForDomain "xample.org") - assertFalse("example.org.gpg" matchedForDomain "e/xample.org") - } - - @Test fun rejectNonGpgComponentMatches() { - assertFalse("work/example.org" matchedForDomain "example.org") - } - - @Test fun rejectsEmailAddresses() { - assertFalse("work/notexample.org/john.doe@example.org.gpg" matchedForDomain "example.org") - assertFalse("work/notexample.org/john.doe@www.example.org.gpg" matchedForDomain "example.org") - assertFalse("work/john.doe@www.example.org/foo.org" matchedForDomain "example.org") - } - - @Test fun rejectsPathSeparators() { - assertNull(SearchableRepositoryViewModel.generateStrictDomainRegex("ex/ample.org")) - } -} diff --git a/app/src/androidTest/java/com/zeapo/pwdstore/model/PasswordEntryAndroidTest.kt b/app/src/androidTest/java/com/zeapo/pwdstore/model/PasswordEntryAndroidTest.kt deleted file mode 100644 index 53df8209..00000000 --- a/app/src/androidTest/java/com/zeapo/pwdstore/model/PasswordEntryAndroidTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.model - -import com.github.michaelbull.result.get -import com.zeapo.pwdstore.utils.Otp -import com.zeapo.pwdstore.utils.UriTotpFinder -import java.util.Date -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue -import org.junit.Test - -class PasswordEntryAndroidTest { - - private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder()) - - @Test fun testGetPassword() { - assertEquals("fooooo", makeEntry("fooooo\nbla\n").password) - assertEquals("fooooo", makeEntry("fooooo\nbla").password) - assertEquals("fooooo", makeEntry("fooooo\n").password) - assertEquals("fooooo", makeEntry("fooooo").password) - assertEquals("", makeEntry("\nblubb\n").password) - assertEquals("", makeEntry("\nblubb").password) - assertEquals("", makeEntry("\n").password) - assertEquals("", makeEntry("").password) - } - - @Test fun testGetExtraContent() { - assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent) - assertEquals("bla", makeEntry("fooooo\nbla").extraContent) - assertEquals("", makeEntry("fooooo\n").extraContent) - assertEquals("", makeEntry("fooooo").extraContent) - assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent) - assertEquals("blubb", makeEntry("\nblubb").extraContent) - assertEquals("", makeEntry("\n").extraContent) - assertEquals("", makeEntry("").extraContent) - } - - @Test fun testGetUsername() { - for (field in PasswordEntry.USERNAME_FIELDS) { - assertEquals("username", makeEntry("\n$field username").username) - assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username) - } - assertEquals( - "username", - makeEntry("secret\nextra\nlogin: username\ncontent\n").username) - assertEquals( - "username", - makeEntry("\nextra\nusername: username\ncontent\n").username) - assertEquals( - "username", makeEntry("\nUSERNaMe: username\ncontent\n").username) - assertEquals("username", makeEntry("\nlogin: username").username) - assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username) - assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username) - assertEquals("username", makeEntry("\nLOGiN:username").username) - assertNull(makeEntry("secret\nextra\ncontent\n").username) - } - - @Test fun testHasUsername() { - assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername()) - assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername()) - assertFalse(makeEntry("secret\nlogin failed\n").hasUsername()) - assertFalse(makeEntry("\n").hasUsername()) - assertFalse(makeEntry("").hasUsername()) - } - - @Test fun testGeneratesOtpFromTotpUri() { - val entry = makeEntry("secret\nextra\n$TOTP_URI") - assertTrue(entry.hasTotp()) - val code = Otp.calculateCode( - entry.totpSecret!!, - // The hardcoded date value allows this test to stay reproducible. - Date(8640000).time / (1000 * entry.totpPeriod), - entry.totpAlgorithm, - entry.digits - ).get() - assertNotNull(code) { "Generated OTP cannot be null" } - assertEquals(entry.digits.toInt(), code.length) - assertEquals("545293", code) - } - - @Test fun testGeneratesOtpWithOnlyUriInFile() { - val entry = makeEntry(TOTP_URI) - assertTrue(entry.password.isEmpty()) - assertTrue(entry.hasTotp()) - val code = Otp.calculateCode( - entry.totpSecret!!, - // The hardcoded date value allows this test to stay reproducible. - Date(8640000).time / (1000 * entry.totpPeriod), - entry.totpAlgorithm, - entry.digits - ).get() - assertNotNull(code) { "Generated OTP cannot be null" } - assertEquals(entry.digits.toInt(), code.length) - assertEquals("545293", code) - } - - @Test fun testOnlyLooksForUriInFirstLine() { - val entry = makeEntry("id:\n$TOTP_URI") - assertTrue(entry.password.isNotEmpty()) - assertTrue(entry.hasTotp()) - assertFalse(entry.hasUsername()) - } - - companion object { - - const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30" - } -} diff --git a/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt b/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt deleted file mode 100644 index 0e4ac51c..00000000 --- a/app/src/androidTest/java/com/zeapo/pwdstore/utils/UriTotpFinderTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.utils - -import kotlin.test.assertEquals -import org.junit.Test - -class UriTotpFinderTest { - - private val totpFinder = UriTotpFinder() - - @Test - fun findSecret() { - assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI)) - assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")) - assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT)) - } - - @Test - fun findDigits() { - assertEquals("12", totpFinder.findDigits(TOTP_URI)) - assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT)) - } - - @Test - fun findPeriod() { - assertEquals(25, totpFinder.findPeriod(TOTP_URI)) - assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT)) - } - - @Test - fun findAlgorithm() { - assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI)) - assertEquals("SHA256", totpFinder.findAlgorithm(PASS_FILE_CONTENT)) - } - - companion object { - - const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25" - const val PASS_FILE_CONTENT = "password\n$TOTP_URI" - } -} diff --git a/app/src/androidTest/java/dev/msfjarvis/aps/data/password/PasswordEntryAndroidTest.kt b/app/src/androidTest/java/dev/msfjarvis/aps/data/password/PasswordEntryAndroidTest.kt new file mode 100644 index 00000000..521b4c4f --- /dev/null +++ b/app/src/androidTest/java/dev/msfjarvis/aps/data/password/PasswordEntryAndroidTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.data.password + +import com.github.michaelbull.result.get +import dev.msfjarvis.aps.util.totp.Otp +import dev.msfjarvis.aps.util.totp.UriTotpFinder +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.Test + +class PasswordEntryAndroidTest { + + private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder()) + + @Test fun testGetPassword() { + assertEquals("fooooo", makeEntry("fooooo\nbla\n").password) + assertEquals("fooooo", makeEntry("fooooo\nbla").password) + assertEquals("fooooo", makeEntry("fooooo\n").password) + assertEquals("fooooo", makeEntry("fooooo").password) + assertEquals("", makeEntry("\nblubb\n").password) + assertEquals("", makeEntry("\nblubb").password) + assertEquals("", makeEntry("\n").password) + assertEquals("", makeEntry("").password) + } + + @Test fun testGetExtraContent() { + assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent) + assertEquals("bla", makeEntry("fooooo\nbla").extraContent) + assertEquals("", makeEntry("fooooo\n").extraContent) + assertEquals("", makeEntry("fooooo").extraContent) + assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent) + assertEquals("blubb", makeEntry("\nblubb").extraContent) + assertEquals("", makeEntry("\n").extraContent) + assertEquals("", makeEntry("").extraContent) + } + + @Test fun testGetUsername() { + for (field in PasswordEntry.USERNAME_FIELDS) { + assertEquals("username", makeEntry("\n$field username").username) + assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username) + } + assertEquals( + "username", + makeEntry("secret\nextra\nlogin: username\ncontent\n").username) + assertEquals( + "username", + makeEntry("\nextra\nusername: username\ncontent\n").username) + assertEquals( + "username", makeEntry("\nUSERNaMe: username\ncontent\n").username) + assertEquals("username", makeEntry("\nlogin: username").username) + assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username) + assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username) + assertEquals("username", makeEntry("\nLOGiN:username").username) + assertNull(makeEntry("secret\nextra\ncontent\n").username) + } + + @Test fun testHasUsername() { + assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername()) + assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername()) + assertFalse(makeEntry("secret\nlogin failed\n").hasUsername()) + assertFalse(makeEntry("\n").hasUsername()) + assertFalse(makeEntry("").hasUsername()) + } + + @Test fun testGeneratesOtpFromTotpUri() { + val entry = makeEntry("secret\nextra\n$TOTP_URI") + assertTrue(entry.hasTotp()) + val code = Otp.calculateCode( + entry.totpSecret!!, + // The hardcoded date value allows this test to stay reproducible. + Date(8640000).time / (1000 * entry.totpPeriod), + entry.totpAlgorithm, + entry.digits + ).get() + assertNotNull(code) { "Generated OTP cannot be null" } + assertEquals(entry.digits.toInt(), code.length) + assertEquals("545293", code) + } + + @Test fun testGeneratesOtpWithOnlyUriInFile() { + val entry = makeEntry(TOTP_URI) + assertTrue(entry.password.isEmpty()) + assertTrue(entry.hasTotp()) + val code = Otp.calculateCode( + entry.totpSecret!!, + // The hardcoded date value allows this test to stay reproducible. + Date(8640000).time / (1000 * entry.totpPeriod), + entry.totpAlgorithm, + entry.digits + ).get() + assertNotNull(code) { "Generated OTP cannot be null" } + assertEquals(entry.digits.toInt(), code.length) + assertEquals("545293", code) + } + + @Test fun testOnlyLooksForUriInFirstLine() { + val entry = makeEntry("id:\n$TOTP_URI") + assertTrue(entry.password.isNotEmpty()) + assertTrue(entry.hasTotp()) + assertFalse(entry.hasUsername()) + } + + companion object { + + const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30" + } +} diff --git a/app/src/androidTest/java/dev/msfjarvis/aps/util/settings/MigrationsTest.kt b/app/src/androidTest/java/dev/msfjarvis/aps/util/settings/MigrationsTest.kt new file mode 100644 index 00000000..d27b4c1c --- /dev/null +++ b/app/src/androidTest/java/dev/msfjarvis/aps/util/settings/MigrationsTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +@file:Suppress("DEPRECATION") + +package dev.msfjarvis.aps.util.settings + +import android.content.Context +import androidx.core.content.edit +import dev.msfjarvis.aps.Application +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class MigrationsTest { + + private fun checkOldKeysAreRemoved(context: Context) = with(context.sharedPrefs) { + assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT)) + assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME)) + assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER)) + assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION)) + assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL)) + } + + @Test + fun verifySshWithCustomPortMigration() { + val context = Application.instance.applicationContext + context.sharedPrefs.edit { + clear() + putString(PreferenceKeys.GIT_REMOTE_PORT, "2200") + putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") + putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo") + putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102") + putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref) + putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.Password.pref) + } + runMigrations(context) + checkOldKeysAreRemoved(context) + assertEquals( + context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL), + "ssh://msfjarvis@192.168.0.102:2200/mnt/disk3/pass-repo" + ) + } + + @Test + fun verifySshWithDefaultPortMigration() { + val context = Application.instance.applicationContext + context.sharedPrefs.edit { + clear() + putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") + putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo") + putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102") + putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref) + putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.SshKey.pref) + } + runMigrations(context) + checkOldKeysAreRemoved(context) + assertEquals( + context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL), + "msfjarvis@192.168.0.102:/mnt/disk3/pass-repo" + ) + } + + @Test + fun verifyHttpsWithGitHubMigration() { + val context = Application.instance.applicationContext + context.sharedPrefs.edit { + clear() + putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") + putString(PreferenceKeys.GIT_REMOTE_LOCATION, "Android-Password-Store/pass-test") + putString(PreferenceKeys.GIT_REMOTE_SERVER, "github.com") + putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Https.pref) + putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.None.pref) + } + runMigrations(context) + checkOldKeysAreRemoved(context) + assertEquals( + context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL), + "https://github.com/Android-Password-Store/pass-test" + ) + } + + @Test + fun verifyHiddenFoldersMigrationIfDisabled() { + val context = Application.instance.applicationContext + context.sharedPrefs.edit { clear() } + runMigrations(context) + assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true)) + assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)) + } + + @Test + fun verifyHiddenFoldersMigrationIfEnabled() { + val context = Application.instance.applicationContext + context.sharedPrefs.edit { + clear() + putBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true) + } + runMigrations(context) + assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)) + assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)) + } +} diff --git a/app/src/androidTest/java/dev/msfjarvis/aps/util/totp/UriTotpFinderTest.kt b/app/src/androidTest/java/dev/msfjarvis/aps/util/totp/UriTotpFinderTest.kt new file mode 100644 index 00000000..6dba51b3 --- /dev/null +++ b/app/src/androidTest/java/dev/msfjarvis/aps/util/totp/UriTotpFinderTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.totp + +import kotlin.test.assertEquals +import org.junit.Test + +class UriTotpFinderTest { + + private val totpFinder = UriTotpFinder() + + @Test + fun findSecret() { + assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI)) + assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")) + assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT)) + } + + @Test + fun findDigits() { + assertEquals("12", totpFinder.findDigits(TOTP_URI)) + assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT)) + } + + @Test + fun findPeriod() { + assertEquals(25, totpFinder.findPeriod(TOTP_URI)) + assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT)) + } + + @Test + fun findAlgorithm() { + assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI)) + assertEquals("SHA256", totpFinder.findAlgorithm(PASS_FILE_CONTENT)) + } + + companion object { + + const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25" + const val PASS_FILE_CONTENT = "password\n$TOTP_URI" + } +} diff --git a/app/src/androidTest/java/dev/msfjarvis/aps/util/viewmodel/StrictDomainRegexTest.kt b/app/src/androidTest/java/dev/msfjarvis/aps/util/viewmodel/StrictDomainRegexTest.kt new file mode 100644 index 00000000..876a0478 --- /dev/null +++ b/app/src/androidTest/java/dev/msfjarvis/aps/util/viewmodel/StrictDomainRegexTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.viewmodel + +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import org.junit.Test + +private infix fun String.matchedForDomain(domain: String) = + SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true + +class StrictDomainRegexTest { + + @Test fun acceptsLiteralDomain() { + assertTrue("work/example.org/john.doe@example.org.gpg" matchedForDomain "example.org") + assertTrue("example.org/john.doe@example.org.gpg" matchedForDomain "example.org") + assertTrue("example.org.gpg" matchedForDomain "example.org") + } + + @Test fun acceptsSubdomains() { + assertTrue("work/www.example.org/john.doe@example.org.gpg" matchedForDomain "example.org") + assertTrue("www2.example.org/john.doe@example.org.gpg" matchedForDomain "example.org") + assertTrue("www.login.example.org.gpg" matchedForDomain "example.org") + } + + @Test fun rejectsPhishingAttempts() { + assertFalse("example.org.gpg" matchedForDomain "xample.org") + assertFalse("login.example.org.gpg" matchedForDomain "xample.org") + assertFalse("example.org/john.doe@exmple.org.gpg" matchedForDomain "xample.org") + assertFalse("example.org.gpg" matchedForDomain "e/xample.org") + } + + @Test fun rejectNonGpgComponentMatches() { + assertFalse("work/example.org" matchedForDomain "example.org") + } + + @Test fun rejectsEmailAddresses() { + assertFalse("work/notexample.org/john.doe@example.org.gpg" matchedForDomain "example.org") + assertFalse("work/notexample.org/john.doe@www.example.org.gpg" matchedForDomain "example.org") + assertFalse("work/john.doe@www.example.org/foo.org" matchedForDomain "example.org") + } + + @Test fun rejectsPathSeparators() { + assertNull(SearchableRepositoryViewModel.generateStrictDomainRegex("ex/ample.org")) + } +} diff --git a/app/src/free/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt b/app/src/free/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt deleted file mode 100644 index 0734af40..00000000 --- a/app/src/free/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSmsActivity.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo.ui - -import android.content.Context -import android.content.IntentSender -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity - -@RequiresApi(Build.VERSION_CODES.O) -@Suppress("UNUSED_PARAMETER") -class AutofillSmsActivity : AppCompatActivity() { - - companion object { - - fun shouldOfferFillFromSms(context: Context): Boolean { - return false - } - - fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender { - throw NotImplementedError("Filling OTPs from SMS requires non-free dependencies") - } - } -} diff --git a/app/src/free/java/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt b/app/src/free/java/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt new file mode 100644 index 00000000..3ed0c1d5 --- /dev/null +++ b/app/src/free/java/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt @@ -0,0 +1,27 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.autofill.oreo.ui + +import android.content.Context +import android.content.IntentSender +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity + +@RequiresApi(Build.VERSION_CODES.O) +@Suppress("UNUSED_PARAMETER") +class AutofillSmsActivity : AppCompatActivity() { + + companion object { + + fun shouldOfferFillFromSms(context: Context): Boolean { + return false + } + + fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender { + throw NotImplementedError("Filling OTPs from SMS requires non-free dependencies") + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 72714908..609b4c86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,7 +5,7 @@ @@ -25,7 +25,7 @@ tools:ignore="GoogleAppIndexingWarning"> @@ -37,7 +37,7 @@ android:windowSoftInputMode="adjustResize" /> @@ -56,48 +56,48 @@ tools:node="replace" /> + android:parentActivityName=".ui.passwords.PasswordStore" /> @@ -107,21 +107,21 @@ android:resource="@xml/oreo_autofill_service" /> - + diff --git a/app/src/main/java/com/zeapo/pwdstore/Application.kt b/app/src/main/java/com/zeapo/pwdstore/Application.kt deleted file mode 100644 index 3f3963d0..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/Application.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import android.content.SharedPreferences -import androidx.appcompat.app.AppCompatDelegate -import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY -import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO -import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES -import com.github.ajalt.timberkt.Timber.DebugTree -import com.github.ajalt.timberkt.Timber.plant -import com.zeapo.pwdstore.git.sshj.setUpBouncyCastleForSshj -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.ProxyUtils -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs - -@Suppress("Unused") -class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener { - - private val prefs by lazy { sharedPrefs } - - override fun onCreate() { - super.onCreate() - instance = this - if (BuildConfig.ENABLE_DEBUG_FEATURES || - prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) { - plant(DebugTree()) - } - prefs.registerOnSharedPreferenceChangeListener(this) - setNightMode() - setUpBouncyCastleForSshj() - runMigrations(applicationContext) - ProxyUtils.setDefaultProxy() - } - - override fun onTerminate() { - prefs.unregisterOnSharedPreferenceChangeListener(this) - super.onTerminate() - } - - override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) { - if (key == PreferenceKeys.APP_THEME) { - setNightMode() - } - } - - private fun setNightMode() { - AppCompatDelegate.setDefaultNightMode(when (prefs.getString(PreferenceKeys.APP_THEME) - ?: getString(R.string.app_theme_def)) { - "light" -> MODE_NIGHT_NO - "dark" -> MODE_NIGHT_YES - "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM - else -> MODE_NIGHT_AUTO_BATTERY - }) - } - - companion object { - - lateinit var instance: Application - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt b/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt deleted file mode 100644 index f50ac7ff..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ClipboardService.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.ClipData -import android.content.Intent -import android.os.Build -import android.os.IBinder -import androidx.annotation.RequiresApi -import androidx.core.app.NotificationCompat -import androidx.core.content.getSystemService -import com.github.ajalt.timberkt.d -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.clipboard -import com.zeapo.pwdstore.utils.sharedPrefs -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class ClipboardService : Service() { - - private val scope = CoroutineScope(Job() + Dispatchers.Main) - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent != null) { - when (intent.action) { - ACTION_CLEAR -> { - clearClipboard() - stopForeground(true) - stopSelf() - return super.onStartCommand(intent, flags, startId) - } - - ACTION_START -> { - val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45) - - if (time == 0) { - stopSelf() - } - - createNotification(time) - scope.launch { - withContext(Dispatchers.IO) { - startTimer(time) - } - withContext(Dispatchers.Main) { - clearClipboard() - stopForeground(true) - stopSelf() - } - } - return START_NOT_STICKY - } - } - } - - return super.onStartCommand(intent, flags, startId) - } - - override fun onBind(intent: Intent?): IBinder? { - return null - } - - override fun onDestroy() { - scope.cancel() - super.onDestroy() - } - - private fun clearClipboard() { - val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false) - val clipboard = clipboard - - if (clipboard != null) { - scope.launch { - d { "Clearing the clipboard" } - val clip = ClipData.newPlainText("pgp_handler_result_pm", "") - clipboard.setPrimaryClip(clip) - if (deepClear) { - withContext(Dispatchers.IO) { - repeat(20) { - val count = (it * 500).toString() - clipboard.setPrimaryClip(ClipData.newPlainText(count, count)) - } - } - } - } - } else { - d { "Cannot get clipboard manager service" } - } - } - - private suspend fun startTimer(showTime: Int) { - var current = 0 - while (scope.isActive && current < showTime) { - // Block for 1s or until cancel is signalled - current++ - delay(1000) - } - } - - private fun createNotification(clearTime: Int) { - val clearTimeMs = clearTime * 1000L - val clearIntent = Intent(this, ClipboardService::class.java).apply { - action = ACTION_CLEAR - } - val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) - } else { - PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) - } - val notification = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { - createNotificationApi23(pendingIntent) - } else { - createNotificationApi24(pendingIntent, clearTimeMs) - } - - createNotificationChannel() - startForeground(1, notification) - } - - private fun createNotificationApi23(pendingIntent: PendingIntent): Notification { - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.tap_clear_clipboard)) - .setSmallIcon(R.drawable.ic_action_secure_24dp) - .setContentIntent(pendingIntent) - .setUsesChronometer(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - } - - @RequiresApi(Build.VERSION_CODES.N) - private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification { - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.tap_clear_clipboard)) - .setSmallIcon(R.drawable.ic_action_secure_24dp) - .setContentIntent(pendingIntent) - .setUsesChronometer(true) - .setChronometerCountDown(true) - .setShowWhen(true) - .setWhen(System.currentTimeMillis() + clearTimeMs) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val serviceChannel = NotificationChannel( - CHANNEL_ID, - getString(R.string.app_name), - NotificationManager.IMPORTANCE_LOW - ) - val manager = getSystemService() - if (manager != null) { - manager.createNotificationChannel(serviceChannel) - } else { - d { "Failed to create notification channel" } - } - } - } - - companion object { - - const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER" - const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME" - private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD" - private const val CHANNEL_ID = "NotificationService" - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt b/app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt deleted file mode 100644 index 3ad6346d..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/LaunchActivity.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import 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 com.zeapo.pwdstore.crypto.DecryptActivity -import com.zeapo.pwdstore.utils.BiometricAuthenticator -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.sharedPrefs - -class LaunchActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val prefs = sharedPrefs - if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) { - BiometricAuthenticator.authenticate(this) { - when (it) { - is BiometricAuthenticator.Result.Success -> { - startTargetActivity(false) - } - is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> { - prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) } - startTargetActivity(false) - } - is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> { - finish() - } - } - } - } else { - startTargetActivity(true) - } - } - - private fun startTargetActivity(noAuth: Boolean) { - val intentToStart = if (intent.action == ACTION_DECRYPT_PASS) - Intent(this, DecryptActivity::class.java).apply { - putExtra("NAME", intent.getStringExtra("NAME")) - putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH")) - putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH")) - putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L)) - } - else - Intent(this, PasswordStore::class.java) - startActivity(intentToStart) - - Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L) - } - - companion object { - - const val ACTION_DECRYPT_PASS = "DECRYPT_PASS" - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/Migrations.kt b/app/src/main/java/com/zeapo/pwdstore/Migrations.kt deleted file mode 100644 index 18aed20e..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/Migrations.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -@file:Suppress("DEPRECATION") - -package com.zeapo.pwdstore - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import com.github.ajalt.timberkt.e -import com.github.ajalt.timberkt.i -import com.github.michaelbull.result.get -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.config.Protocol -import com.zeapo.pwdstore.git.sshj.SshKey -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.File -import java.net.URI - -fun runMigrations(context: Context) { - val sharedPrefs = context.sharedPrefs - migrateToGitUrlBasedConfig(sharedPrefs) - migrateToHideAll(sharedPrefs) - migrateToSshKey(context, sharedPrefs) -} - -private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) { - val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) - ?: return - i { "Migrating to URL-based Git config" } - val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: "" - val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: "" - val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: "" - val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL)) - - // Whether we need the leading ssh:// depends on the use of a custom port. - val hostnamePart = serverHostname.removePrefix("ssh://") - val url = when (protocol) { - Protocol.Ssh -> { - val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@" - val portPart = - if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort" - if (portPart.isEmpty()) { - "$userPart$hostnamePart:$serverPath" - } else { - // Only absolute paths are supported with custom ports. - if (!serverPath.startsWith('/')) - null - else - // We have to specify the ssh scheme as this is the only way to pass a custom - // port. - "ssh://$userPart$hostnamePart$portPart$serverPath" - } - } - Protocol.Https -> { - val portPart = - if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort" - val pathPart = serverPath.trimStart('/', ':') - val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart" - val url = when { - urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme - urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https") - else -> "https://$urlWithFreeEntryScheme" - } - runCatching { - if (URI(url).rawAuthority != null) - url - else - null - }.get() - } - } - - sharedPrefs.edit { - remove(PreferenceKeys.GIT_REMOTE_LOCATION) - remove(PreferenceKeys.GIT_REMOTE_PORT) - remove(PreferenceKeys.GIT_REMOTE_SERVER) - remove(PreferenceKeys.GIT_REMOTE_USERNAME) - remove(PreferenceKeys.GIT_REMOTE_PROTOCOL) - } - if (url == null || GitSettings.updateConnectionSettingsIfValid( - newAuthMode = GitSettings.authMode, - newUrl = url, - newBranch = GitSettings.branch) != GitSettings.UpdateConnectionSettingsResult.Valid) { - e { "Failed to migrate to URL-based Git config, generated URL is invalid" } - } -} - -private fun migrateToHideAll(sharedPrefs: SharedPreferences) { - sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return - val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) - sharedPrefs.edit { - remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS) - putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden) - } -} - -private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) { - val privateKeyFile = File(context.filesDir, ".ssh_key") - if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && - !SshKey.exists && - privateKeyFile.exists()) { - // Currently uses a private key imported or generated with an old version of Password Store. - // Generated keys come with a public key which the user should still be able to view after - // the migration (not possible for regular imported keys), hence the special case. - val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false) - SshKey.useLegacyKey(isGeneratedKey) - sharedPrefs.edit { - remove(PreferenceKeys.USE_GENERATED_KEY) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordExportService.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordExportService.kt deleted file mode 100644 index fc2b7f10..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordExportService.kt +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.Service -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.IBinder -import androidx.core.app.NotificationCompat -import androidx.core.content.getSystemService -import androidx.documentfile.provider.DocumentFile -import com.github.ajalt.timberkt.d -import com.zeapo.pwdstore.utils.PasswordRepository -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.Calendar -import java.util.TimeZone - -class PasswordExportService : Service() { - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent != null) { - when (intent.action) { - ACTION_EXPORT_PASSWORD -> { - val uri = intent.getParcelableExtra("uri") - if (uri != null) { - val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri) - - if (targetDirectory != null) { - createNotification() - exportPasswords(targetDirectory) - stopSelf() - return START_NOT_STICKY - } - } - } - } - } - return super.onStartCommand(intent, flags, startId) - } - - override fun onBind(intent: Intent?): IBinder? { - return null - } - - /** - * Exports passwords to the given directory. - * - * Recursively copies the existing password store to an external directory. - * - * @param targetDirectory directory to copy password directory to. - */ - private fun exportPasswords(targetDirectory: DocumentFile) { - - val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory()) - val sourcePassDir = DocumentFile.fromFile(repositoryDirectory) - - d { "Copying ${repositoryDirectory.path} to $targetDirectory" } - - val dateString = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - LocalDateTime - .now() - .format(DateTimeFormatter.ISO_DATE_TIME) - } else { - String.format("%tFT% - if (file.isDirectory) { - // Create new directory and recurse - val newDir = targetDirectory.createDirectory(file.name!!) - copyDirToDir(file, newDir!!) - } else { - copyFileToDir(file, targetDirectory) - } - } - } - - private fun createNotification() { - createNotificationChannel() - - val notification = NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.app_name)) - .setContentText(getString(R.string.exporting_passwords)) - .setSmallIcon(R.drawable.ic_round_import_export) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - - startForeground(2, notification) - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val serviceChannel = NotificationChannel( - CHANNEL_ID, - getString(R.string.app_name), - NotificationManager.IMPORTANCE_LOW - ) - val manager = getSystemService() - if (manager != null) { - manager.createNotificationChannel(serviceChannel) - } else { - d { "Failed to create notification channel" } - } - } - } - - companion object { - - const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD" - private const val CHANNEL_ID = "NotificationService" - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt deleted file mode 100644 index ffbe5bc8..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordFragment.kt +++ /dev/null @@ -1,342 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import android.content.Context -import android.content.SharedPreferences -import android.os.Bundle -import android.os.Parcelable -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.appcompat.view.ActionMode -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.runCatching -import com.github.michaelbull.result.onFailure -import com.zeapo.pwdstore.databinding.PasswordRecyclerViewBinding -import com.zeapo.pwdstore.git.BaseGitActivity -import com.zeapo.pwdstore.git.GitServerConfigActivity -import com.zeapo.pwdstore.git.config.AuthMode -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.ui.OnOffItemAnimator -import com.zeapo.pwdstore.ui.adapters.PasswordItemRecyclerAdapter -import com.zeapo.pwdstore.ui.dialogs.BasicBottomSheet -import com.zeapo.pwdstore.ui.dialogs.ItemCreationBottomSheet -import com.zeapo.pwdstore.utils.PasswordItem -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PasswordSortOrder -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.base64 -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -import com.zeapo.pwdstore.utils.viewBinding -import java.io.File -import kotlinx.coroutines.launch -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -class PasswordFragment : Fragment(R.layout.password_recycler_view) { - - private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter - private lateinit var listener: OnFragmentInteractionListener - private lateinit var settings: SharedPreferences - - private var recyclerViewStateToRestore: Parcelable? = null - private var actionMode: ActionMode? = null - private var scrollTarget: File? = null - - private val model: SearchableRepositoryViewModel by activityViewModels() - private val binding by viewBinding(PasswordRecyclerViewBinding::bind) - private val swipeResult = registerForActivityResult(StartActivityForResult()) { - binding.swipeRefresher.isRefreshing = false - requireStore().refreshPasswordList() - } - - val currentDir: File - get() = model.currentDir.value!! - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settings = requireContext().sharedPrefs - initializePasswordList() - binding.fab.setOnClickListener { - ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") - } - childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle -> - when (bundle.getString(ACTION_KEY)) { - ACTION_FOLDER -> requireStore().createFolder() - ACTION_PASSWORD -> requireStore().createPassword() - } - } - } - - private fun initializePasswordList() { - val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git") - val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true) - binding.swipeRefresher.setOnRefreshListener { - if (!hasGitDir) { - requireStore().refreshPasswordList() - binding.swipeRefresher.isRefreshing = false - } else if (!PasswordRepository.isGitRepo()) { - BasicBottomSheet.Builder(requireContext()) - .setMessageRes(R.string.clone_git_repo) - .setPositiveButtonClickListener(getString(R.string.clone_button)) { - swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext())) - } - .build() - .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO") - binding.swipeRefresher.isRefreshing = false - } else { - // When authentication is set to AuthMode.None then the only git operation we can - // run is a pull, so automatically fallback to that. - val operationId = when (GitSettings.authMode) { - AuthMode.None -> BaseGitActivity.GitOp.PULL - else -> BaseGitActivity.GitOp.SYNC - } - requireStore().apply { - lifecycleScope.launch { - launchGitOperation(operationId).fold( - success = { - binding.swipeRefresher.isRefreshing = false - refreshPasswordList() - }, - failure = { err -> - promptOnErrorHandler(err) { - binding.swipeRefresher.isRefreshing = false - } - }, - ) - } - } - } - } - - recyclerAdapter = PasswordItemRecyclerAdapter() - .onItemClicked { _, item -> - listener.onFragmentInteraction(item) - } - .onSelectionChanged { selection -> - // In order to not interfere with drag selection, we disable the SwipeRefreshLayout - // once an item is selected. - binding.swipeRefresher.isEnabled = selection.isEmpty - - if (actionMode == null) - actionMode = requireStore().startSupportActionMode(actionModeCallback) - ?: return@onSelectionChanged - - if (!selection.isEmpty) { - actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size()) - actionMode!!.invalidate() - } else { - actionMode!!.finish() - } - } - val recyclerView = binding.passRecycler - recyclerView.apply { - addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)) - layoutManager = LinearLayoutManager(requireContext()) - itemAnimator = OnOffItemAnimator() - adapter = recyclerAdapter - } - - FastScrollerBuilder(recyclerView).build() - recyclerAdapter.makeSelectable(recyclerView) - registerForContextMenu(recyclerView) - - val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) - model.navigateTo(File(path), pushPreviousLocation = false) - model.searchResult.observe(viewLifecycleOwner) { result -> - // Only run animations when the new list is filtered, i.e., the user submitted a search, - // and not on folder navigations since the latter leads to too many removal animations. - (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered - recyclerAdapter.submitList(result.passwordItems) { - when { - result.isFiltered -> { - // When the result is filtered, we always scroll to the top since that is where - // the best fuzzy match appears. - recyclerView.scrollToPosition(0) - } - scrollTarget != null -> { - scrollTarget?.let { - recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) - } - scrollTarget = null - } - else -> { - // When the result is not filtered and there is a saved scroll position for it, - // we try to restore it. - recyclerViewStateToRestore?.let { - recyclerView.layoutManager!!.onRestoreInstanceState(it) - } - recyclerViewStateToRestore = null - } - } - } - } - } - - private val actionModeCallback = object : ActionMode.Callback { - // Called when the action mode is created; startActionMode() was called - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - // Inflate a menu resource providing context menu items - mode.menuInflater.inflate(R.menu.context_pass, menu) - // hide the fab - animateFab(false) - return true - } - - // Called each time the action mode is shown. Always called after onCreateActionMode, but - // may be called multiple times if the mode is invalidated. - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { - menu.findItem(R.id.menu_edit_password).isVisible = - recyclerAdapter.getSelectedItems() - .all { it.type == PasswordItem.TYPE_CATEGORY } - return true - } - - // Called when the user selects a contextual menu item - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - return when (item.itemId) { - R.id.menu_delete_password -> { - requireStore().deletePasswords(recyclerAdapter.getSelectedItems()) - // Action picked, so close the CAB - mode.finish() - true - } - R.id.menu_move_password -> { - requireStore().movePasswords(recyclerAdapter.getSelectedItems()) - false - } - R.id.menu_edit_password -> { - requireStore().renameCategory(recyclerAdapter.getSelectedItems()) - mode.finish() - false - } - else -> false - } - } - - // Called when the user exits the action mode - override fun onDestroyActionMode(mode: ActionMode) { - recyclerAdapter.requireSelectionTracker().clearSelection() - actionMode = null - // show the fab - animateFab(true) - } - - private fun animateFab(show: Boolean) = with(binding.fab) { - val animation = AnimationUtils.loadAnimation( - context, if (show) R.anim.scale_up else R.anim.scale_down - ) - animation.setAnimationListener(object : Animation.AnimationListener { - override fun onAnimationRepeat(animation: Animation?) { - } - - override fun onAnimationEnd(animation: Animation?) { - if (!show) visibility = View.GONE - } - - override fun onAnimationStart(animation: Animation?) { - if (show) visibility = View.VISIBLE - } - }) - animate().rotationBy(if (show) -90f else 90f) - .setStartDelay(if (show) 100 else 0) - .setDuration(100) - .start() - startAnimation(animation) - } - } - - override fun onAttach(context: Context) { - super.onAttach(context) - runCatching { - listener = object : OnFragmentInteractionListener { - override fun onFragmentInteraction(item: PasswordItem) { - if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) { - //save the time when password was used - val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - preferences.edit { - putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) - } - } - - if (item.type == PasswordItem.TYPE_CATEGORY) { - navigateTo(item.file) - } else { - if (requireArguments().getBoolean("matchWith", false)) { - requireStore().matchPasswordWithApp(item) - } else { - requireStore().decryptPassword(item) - } - } - } - } - }.onFailure { - throw ClassCastException("$context must implement OnFragmentInteractionListener") - } - } - - private fun requireStore() = requireActivity() as PasswordStore - - /** - * Returns true if the back press was handled by the [Fragment]. - */ - fun onBackPressedInActivity(): Boolean { - if (!model.canNavigateBack) - return false - // The RecyclerView state is restored when the asynchronous update operation on the - // adapter is completed. - recyclerViewStateToRestore = model.navigateBack() - if (!model.canNavigateBack) - requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false) - return true - } - - fun dismissActionMode() { - actionMode?.finish() - } - - companion object { - - const val ITEM_CREATION_REQUEST_KEY = "creation_key" - const val ACTION_KEY = "action" - const val ACTION_FOLDER = "folder" - const val ACTION_PASSWORD = "password" - - fun newInstance(args: Bundle): PasswordFragment { - val fragment = PasswordFragment() - fragment.arguments = args - return fragment - } - } - - - fun navigateTo(file: File) { - requireStore().clearSearch() - model.navigateTo( - file, - recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState() - ) - requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - - fun scrollToOnNextRefresh(file: File) { - scrollTarget = file - } - - interface OnFragmentInteractionListener { - - fun onFragmentInteraction(item: PasswordItem) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt b/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt deleted file mode 100644 index b0f8602c..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/PasswordStore.kt +++ /dev/null @@ -1,692 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import android.Manifest -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.content.pm.ShortcutInfo.Builder -import android.content.pm.ShortcutManager -import android.graphics.drawable.Icon -import android.os.Build -import android.os.Bundle -import android.view.KeyEvent -import android.view.Menu -import android.view.MenuItem -import android.view.MenuItem.OnActionExpandListener -import androidx.activity.result.contract.ActivityResultContracts.RequestPermission -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.viewModels -import androidx.appcompat.widget.SearchView -import androidx.appcompat.widget.SearchView.OnQueryTextListener -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.lifecycleScope -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.e -import com.github.ajalt.timberkt.i -import com.github.ajalt.timberkt.w -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.getOr -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher -import com.zeapo.pwdstore.crypto.BasePgpActivity.Companion.getLongName -import com.zeapo.pwdstore.crypto.DecryptActivity -import com.zeapo.pwdstore.crypto.PasswordCreationActivity -import com.zeapo.pwdstore.git.BaseGitActivity -import com.zeapo.pwdstore.git.config.AuthMode -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.ui.dialogs.BasicBottomSheet -import com.zeapo.pwdstore.ui.dialogs.FolderCreationDialogFragment -import com.zeapo.pwdstore.ui.onboarding.activity.OnboardingActivity -import com.zeapo.pwdstore.utils.PasswordItem -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.base64 -import com.zeapo.pwdstore.utils.commitChange -import com.zeapo.pwdstore.utils.contains -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.isInsideRepository -import com.zeapo.pwdstore.utils.isPermissionGranted -import com.zeapo.pwdstore.utils.listFilesRecursively -import com.zeapo.pwdstore.utils.requestInputFocusOnView -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.File -import java.lang.Character.UnicodeBlock -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.eclipse.jgit.api.Git - -const val PASSWORD_FRAGMENT_TAG = "PasswordsList" - -class PasswordStore : BaseGitActivity() { - - private lateinit var searchItem: MenuItem - private val settings by lazy { sharedPrefs } - - private val model: SearchableRepositoryViewModel by viewModels { - ViewModelProvider.AndroidViewModelFactory(application) - } - - private val storagePermissionRequest = registerForActivityResult(RequestPermission()) { granted -> - if (granted) checkLocalRepository() - } - - private val directorySelectAction = registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - checkLocalRepository() - } - } - - private val listRefreshAction = registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - refreshPasswordList() - } - } - - private val passwordMoveAction = registerForActivityResult(StartActivityForResult()) { result -> - val intentData = result.data ?: return@registerForActivityResult - val filesToMove = requireNotNull(intentData.getStringArrayExtra("Files")) - val target = File(requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH"))) - val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath - if (!target.isDirectory) { - e { "Tried moving passwords to a non-existing folder." } - return@registerForActivityResult - } - - d { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" } - d { filesToMove.joinToString(", ") } - - lifecycleScope.launch(Dispatchers.IO) { - for (file in filesToMove) { - val source = File(file) - if (!source.exists()) { - e { "Tried moving something that appears non-existent." } - continue - } - val destinationFile = File(target.absolutePath + "/" + source.name) - val basename = source.nameWithoutExtension - val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename) - val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) - if (destinationFile.exists()) { - e { "Trying to move a file that already exists." } - withContext(Dispatchers.Main) { - MaterialAlertDialogBuilder(this@PasswordStore) - .setTitle(resources.getString(R.string.password_exists_title)) - .setMessage(resources.getString( - R.string.password_exists_message, - destinationLongName, - sourceLongName) - ) - .setPositiveButton(R.string.dialog_ok) { _, _ -> - launch(Dispatchers.IO) { - moveFile(source, destinationFile) - } - } - .setNegativeButton(R.string.dialog_cancel, null) - .show() - } - } else { - launch(Dispatchers.IO) { - moveFile(source, destinationFile) - } - } - } - when (filesToMove.size) { - 1 -> { - val source = File(filesToMove[0]) - val basename = source.nameWithoutExtension - val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename) - val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) - withContext(Dispatchers.Main) { - commitChange( - resources.getString(R.string.git_commit_move_text, sourceLongName, destinationLongName), - ) - } - } - else -> { - val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath - val relativePath = getRelativePath("${target.absolutePath}/", repoDir) - withContext(Dispatchers.Main) { - commitChange( - resources.getString(R.string.git_commit_move_multiple_text, relativePath), - ) - } - } - } - } - refreshPasswordList() - getPasswordFragment()?.dismissActionMode() - } - - override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { - // open search view on search key, or Ctr+F - if ((keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && - !searchItem.isActionViewExpanded) { - searchItem.expandActionView() - return true - } - - // open search view on any printable character and query for it - val c = event.unicodeChar.toChar() - val printable = isPrintable(c) - if (printable && !searchItem.isActionViewExpanded) { - searchItem.expandActionView() - (searchItem.actionView as SearchView).setQuery(c.toString(), true) - return true - } - return super.onKeyDown(keyCode, event) - } - - @SuppressLint("NewApi") - override fun onCreate(savedInstanceState: Bundle?) { - // If user opens app with permission granted then revokes and returns, - // prevent attempt to create password list fragment - var savedInstance = savedInstanceState - if (savedInstanceState != null && (!settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) || - !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE))) { - savedInstance = null - } - super.onCreate(savedInstance) - setContentView(R.layout.activity_pwdstore) - - model.currentDir.observe(this) { dir -> - val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile - supportActionBar!!.apply { - if (dir != basePath) - title = dir.name - else - setTitle(R.string.app_name) - } - } - } - - override fun onStart() { - super.onStart() - refreshPasswordList() - } - - override fun onResume() { - super.onResume() - if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { - hasRequiredStoragePermissions() - } else { - checkLocalRepository() - } - if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) { - if (!searchItem.isActionViewExpanded) { - searchItem.expandActionView() - } - } - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - val menuRes = when { - GitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth - PasswordRepository.isGitRepo() -> R.menu.main_menu_git - else -> R.menu.main_menu_non_git - } - menuInflater.inflate(menuRes, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onPrepareOptionsMenu(menu: Menu): Boolean { - // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so - // we can get by without any noticeable difference in performance. - invalidateOptionsMenu() - searchItem = menu.findItem(R.id.action_search) - val searchView = searchItem.actionView as SearchView - searchView.setOnQueryTextListener( - object : OnQueryTextListener { - override fun onQueryTextSubmit(s: String): Boolean { - searchView.clearFocus() - return true - } - - override fun onQueryTextChange(s: String): Boolean { - val filter = s.trim() - // List the contents of the current directory if the user enters a blank - // search term. - if (filter.isEmpty()) - model.navigateTo( - newDirectory = model.currentDir.value!!, - pushPreviousLocation = false - ) - else - model.search(filter) - return true - } - }) - - // When using the support library, the setOnActionExpandListener() method is - // static and accepts the MenuItem object as an argument - searchItem.setOnActionExpandListener( - object : OnActionExpandListener { - override fun onMenuItemActionCollapse(item: MenuItem): Boolean { - refreshPasswordList() - return true - } - - override fun onMenuItemActionExpand(item: MenuItem): Boolean { - return true - } - }) - if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) { - searchItem.expandActionView() - } - return super.onPrepareOptionsMenu(menu) - } - - // Handle action bar item clicks here. The action bar will - // automatically handle clicks on the Home/Up button, so long - // as you specify a parent activity in AndroidManifest.xml. - override fun onOptionsItemSelected(item: MenuItem): Boolean { - val id = item.itemId - val initBefore = MaterialAlertDialogBuilder(this) - .setMessage(resources.getString(R.string.creation_dialog_text)) - .setPositiveButton(resources.getString(R.string.dialog_ok), null) - when (id) { - R.id.user_pref -> { - runCatching { - startActivity(Intent(this, UserPreference::class.java)) - }.onFailure { e -> - e.printStackTrace() - } - return true - } - R.id.git_push -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - return false - } - runGitOperation(GitOp.PUSH) - return true - } - R.id.git_pull -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - return false - } - runGitOperation(GitOp.PULL) - return true - } - R.id.git_sync -> { - if (!PasswordRepository.isInitialized) { - initBefore.show() - return false - } - runGitOperation(GitOp.SYNC) - return true - } - R.id.refresh -> { - refreshPasswordList() - return true - } - android.R.id.home -> onBackPressed() - else -> { - } - } - return super.onOptionsItemSelected(item) - } - - override fun onBackPressed() { - if (getPasswordFragment()?.onBackPressedInActivity() != true) - super.onBackPressed() - } - - private fun getPasswordFragment(): PasswordFragment? { - return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment - } - - fun clearSearch() { - if (searchItem.isActionViewExpanded) - searchItem.collapseActionView() - } - - private fun runGitOperation(operation: GitOp) = lifecycleScope.launch { - launchGitOperation(operation).fold( - success = { refreshPasswordList() }, - failure = { promptOnErrorHandler(it) }, - ) - } - - /** - * Validates if storage permission is granted, and requests for it if not. The return value - * is true if the permission has been granted. - */ - private fun hasRequiredStoragePermissions(): Boolean { - return if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - BasicBottomSheet.Builder(this) - .setMessageRes(R.string.access_sdcard_text) - .setPositiveButtonClickListener(getString(R.string.snackbar_action_grant)) { - storagePermissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } - .build() - .show(supportFragmentManager, "STORAGE_PERMISSION_MISSING") - false - } else { - checkLocalRepository() - true - } - } - - private fun checkLocalRepository() { - val repo = PasswordRepository.initialize() - if (repo == null) { - directorySelectAction.launch(UserPreference.createDirectorySelectionIntent(this)) - } else { - checkLocalRepository(PasswordRepository.getRepositoryDirectory()) - } - } - - private fun checkLocalRepository(localDir: File?) { - if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) { - d { "Check, dir: ${localDir.absolutePath}" } - // do not push the fragment if we already have it - if (getPasswordFragment() == null || - settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)) { - settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) } - val args = Bundle() - args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) - - // if the activity was started from the autofill settings, the - // intent is to match a clicked pwd with app. pass this to fragment - if (intent.getBooleanExtra("matchWith", false)) { - args.putBoolean("matchWith", true) - } - supportActionBar?.apply { - show() - setDisplayHomeAsUpEnabled(false) - } - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - supportFragmentManager.commit { - replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG) - } - } - } else { - startActivity(Intent(this, OnboardingActivity::class.java)) - } - } - - private fun getRelativePath(fullPath: String, repositoryPath: String): String { - return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") - } - - private fun getLastChangedTimestamp(fullPath: String): Long { - val repoPath = PasswordRepository.getRepositoryDirectory() - val repository = PasswordRepository.getRepository(repoPath) - if (repository == null) { - d { "getLastChangedTimestamp: No git repository" } - return File(fullPath).lastModified() - } - val git = Git(repository) - val relativePath = getRelativePath(fullPath, repoPath.absolutePath).substring(1) // Removes leading '/' - return runCatching { - val iterator = git.log().addPath(relativePath).call().iterator() - if (!iterator.hasNext()) { - w { "getLastChangedTimestamp: No commits for file: $relativePath" } - return -1 - } - iterator.next().commitTime.toLong() * 1000 - }.getOr(-1) - } - - fun decryptPassword(item: PasswordItem) { - val decryptIntent = Intent(this, DecryptActivity::class.java) - val authDecryptIntent = Intent(this, LaunchActivity::class.java) - for (intent in arrayOf(decryptIntent, authDecryptIntent)) { - intent.putExtra("NAME", item.toString()) - intent.putExtra("FILE_PATH", item.file.absolutePath) - intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath) - intent.putExtra("LAST_CHANGED_TIMESTAMP", getLastChangedTimestamp(item.file.absolutePath)) - } - // Needs an action to be a shortcut intent - authDecryptIntent.action = LaunchActivity.ACTION_DECRYPT_PASS - - startActivity(decryptIntent) - - // Adds shortcut - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - val shortcutManager: ShortcutManager = getSystemService() ?: return - val shortcut = Builder(this, item.fullPathToParent) - .setShortLabel(item.toString()) - .setLongLabel(item.fullPathToParent + item.toString()) - .setIcon(Icon.createWithResource(this, R.drawable.ic_lock_open_24px)) - .setIntent(authDecryptIntent) - .build() - val shortcuts = shortcutManager.dynamicShortcuts - if (shortcuts.size >= shortcutManager.maxShortcutCountPerActivity && shortcuts.size > 0) { - shortcuts.removeAt(shortcuts.size - 1) - shortcuts.add(0, shortcut) - shortcutManager.dynamicShortcuts = shortcuts - } else { - shortcutManager.addDynamicShortcuts(listOf(shortcut)) - } - } - } - - private fun validateState(): Boolean { - if (!PasswordRepository.isInitialized) { - MaterialAlertDialogBuilder(this) - .setMessage(resources.getString(R.string.creation_dialog_text)) - .setPositiveButton(resources.getString(R.string.dialog_ok), null) - .show() - return false - } - return true - } - - fun createPassword() { - if (!validateState()) return - val currentDir = currentDir - i { "Adding file to : ${currentDir.absolutePath}" } - val intent = Intent(this, PasswordCreationActivity::class.java) - intent.putExtra("FILE_PATH", currentDir.absolutePath) - intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath) - listRefreshAction.launch(intent) - } - - fun createFolder() { - if (!validateState()) return - FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null) - } - - fun deletePasswords(selectedItems: List) { - 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.requestInputFocusOnView(R.id.folder_name_text) - 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)) { - e { "Something went wrong while moving $source to $destinationFile." } - withContext(Dispatchers.Main) { - MaterialAlertDialogBuilder(this@PasswordStore) - .setTitle(R.string.password_move_error_title) - .setMessage(getString(R.string.password_move_error_message, source, destinationFile)) - .setCancelable(true) - .setPositiveButton(android.R.string.ok, null) - .show() - } - } else { - AutofillMatcher.updateMatches(this, sourceDestinationMap) - } - } - - fun matchPasswordWithApp(item: PasswordItem) { - val path = item.file - .absolutePath - .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "") - .replace(".gpg", "") - val data = Intent() - data.putExtra("path", path) - setResult(RESULT_OK, data) - finish() - } - - companion object { - - const val REQUEST_ARG_PATH = "PATH" - private fun isPrintable(c: Char): Boolean { - val block = UnicodeBlock.of(c) - return (!Character.isISOControl(c) && - block != null && block !== UnicodeBlock.SPECIALS) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt b/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt deleted file mode 100644 index 7ac2bfc7..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/SearchableRepositoryViewModel.kt +++ /dev/null @@ -1,474 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import 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.zeapo.pwdstore.autofill.oreo.AutofillPreferences -import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure -import com.zeapo.pwdstore.utils.PasswordItem -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PasswordSortOrder -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.File -import java.text.Collator -import java.util.Locale -import java.util.Stack -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.yield -import me.zhanghai.android.fastscroll.PopupTextProvider - -private fun File.toPasswordItem() = if (isFile) - PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory()) -else - PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory()) - -private fun PasswordItem.fuzzyMatch(filter: String): Int { - var i = 0 - var j = 0 - var score = 0 - var bonus = 0 - var bonusIncrement = 0 - - val toMatch = longName - - while (i < filter.length && j < toMatch.length) { - when { - filter[i].isWhitespace() -> i++ - filter[i].toLowerCase() == toMatch[j].toLowerCase() -> { - i++ - bonusIncrement += 1 - bonus += bonusIncrement - score += bonus - } - else -> { - bonus = 0 - bonusIncrement = 0 - } - } - j++ - } - return if (i == filter.length) score else 0 -} - -private val CaseInsensitiveComparator = Collator.getInstance().apply { - strength = Collator.PRIMARY -} - -private fun PasswordItem.Companion.makeComparator( - typeSortOrder: PasswordSortOrder, - directoryStructure: DirectoryStructure -): Comparator { - return when (typeSortOrder) { - PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } - // In order to let INDEPENDENT not distinguish between items based on their type, we simply - // declare them all equal at this stage. - PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 } - PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } - PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator - } - .then(compareBy(nullsLast(CaseInsensitiveComparator)) { - directoryStructure.getIdentifierFor(it.file) - }) - .then(compareBy(nullsLast(CaseInsensitiveComparator)) { - directoryStructure.getUsernameFor(it.file) - }) -} - -val PasswordItem.stableId: String - get() = file.absolutePath - -enum class FilterMode { - NoFilter, - StrictDomain, - Fuzzy -} - -enum class SearchMode { - RecursivelyInSubdirectories, - InCurrentDirectoryOnly -} - -enum class ListMode { - FilesOnly, - DirectoriesOnly, - AllEntries -} - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) { - - private var _updateCounter = 0 - private val updateCounter: Int - get() = _updateCounter - - private fun forceUpdateOnNextSearchAction() { - _updateCounter++ - } - - private val root - get() = PasswordRepository.getRepositoryDirectory() - private val settings by lazy(LazyThreadSafetyMode.NONE) { application.sharedPrefs } - private val showHiddenContents - get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) - private val defaultSearchMode - get() = if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) { - SearchMode.RecursivelyInSubdirectories - } else { - SearchMode.InCurrentDirectoryOnly - } - - private val typeSortOrder - get() = PasswordSortOrder.getSortOrder(settings) - private val directoryStructure - get() = AutofillPreferences.directoryStructure(getApplication()) - private val itemComparator - get() = PasswordItem.makeComparator(typeSortOrder, directoryStructure) - - private data class SearchAction( - val baseDirectory: File, - val filter: String, - val filterMode: FilterMode, - val searchMode: SearchMode, - val listMode: ListMode, - // This counter can be increased to force a reexecution of the search action even if all - // other arguments are left unchanged. - val updateCounter: Int - ) - - private fun makeSearchAction( - baseDirectory: File, - filter: String, - filterMode: FilterMode, - searchMode: SearchMode, - listMode: ListMode - ): SearchAction { - return SearchAction( - baseDirectory = baseDirectory, - filter = filter, - filterMode = filterMode, - searchMode = searchMode, - listMode = listMode, - updateCounter = updateCounter - ) - } - - private fun updateSearchAction(action: SearchAction) = - action.copy(updateCounter = updateCounter) - - private val searchAction = MutableLiveData( - makeSearchAction( - baseDirectory = root, - filter = "", - filterMode = FilterMode.NoFilter, - searchMode = SearchMode.InCurrentDirectoryOnly, - listMode = ListMode.AllEntries - ) - ) - private val searchActionFlow = searchAction.asFlow().distinctUntilChanged() - - data class SearchResult(val passwordItems: List, val isFiltered: Boolean) - - val searchResult = searchActionFlow - .mapLatest { searchAction -> - val listResultFlow = when (searchAction.searchMode) { - SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory) - SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory) - } - val prefilteredResultFlow = when (searchAction.listMode) { - ListMode.FilesOnly -> listResultFlow.filter { it.isFile } - ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory } - ListMode.AllEntries -> listResultFlow - } - val filterModeToUse = - if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode - val passwordList = when (filterModeToUse) { - FilterMode.NoFilter -> { - prefilteredResultFlow - .map { it.toPasswordItem() } - .toList() - .sortedWith(itemComparator) - } - FilterMode.StrictDomain -> { - check(searchAction.listMode == ListMode.FilesOnly) { "Searches with StrictDomain search mode can only list files" } - val regex = generateStrictDomainRegex(searchAction.filter) - if (regex != null) { - prefilteredResultFlow - .filter { absoluteFile -> - regex.containsMatchIn(absoluteFile.relativeTo(root).path) - } - .map { it.toPasswordItem() } - .toList() - .sortedWith(itemComparator) - } else { - emptyList() - } - } - FilterMode.Fuzzy -> { - prefilteredResultFlow - .map { - val item = it.toPasswordItem() - Pair(item.fuzzyMatch(searchAction.filter), item) - } - .filter { it.first > 0 } - .toList() - .sortedWith( - compareByDescending> { it.first }.thenBy( - itemComparator - ) { it.second }) - .map { it.second } - } - } - SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter) - }.asLiveData(Dispatchers.IO) - - private fun shouldTake(file: File) = with(file) { - if (showHiddenContents) return true - if (isDirectory) { - !isHidden - } else { - !isHidden && file.extension == "gpg" - } - } - - private fun listFiles(dir: File): Flow { - return dir.listFiles { file -> shouldTake(file) }?.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 { file -> shouldTake(file) } - } - - 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 viewHolderBinder: 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 { - viewHolderBinder.invoke(this, item) - selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) } - itemView.setOnClickListener { - // Do not emit custom click events while the user is selecting items. - if (selectionTracker?.hasSelection() != true) - onItemClickedListener?.invoke(holder, item) - } - } - } - - final override fun getPopupText(position: Int): String { - return getItem(position).name[0].toString().toUpperCase(Locale.getDefault()) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/SelectFolderActivity.kt b/app/src/main/java/com/zeapo/pwdstore/SelectFolderActivity.kt deleted file mode 100644 index 13b6dcd3..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/SelectFolderActivity.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import 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 com.zeapo.pwdstore.utils.PasswordRepository - - -class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) { - - private lateinit var passwordList: SelectFolderFragment - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - passwordList = SelectFolderFragment() - val args = Bundle() - args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) - - passwordList.arguments = args - - supportActionBar?.show() - - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - - supportFragmentManager.commit { - replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) - } - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.pgp_handler_select_folder, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_CANCELED) - finish() - return true - } - R.id.crypto_select -> selectFolder() - } - return super.onOptionsItemSelected(item) - } - - private fun selectFolder() { - intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath) - setResult(RESULT_OK, intent) - finish() - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt b/app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt deleted file mode 100644 index e0aeaad7..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/SelectFolderFragment.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import android.content.Context -import android.os.Bundle -import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.Fragment -import androidx.fragment.app.activityViewModels -import androidx.recyclerview.widget.LinearLayoutManager -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.databinding.PasswordRecyclerViewBinding -import com.zeapo.pwdstore.ui.adapters.PasswordItemRecyclerAdapter -import com.zeapo.pwdstore.utils.PasswordItem -import com.zeapo.pwdstore.utils.viewBinding -import java.io.File -import me.zhanghai.android.fastscroll.FastScrollerBuilder - -class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { - - private val binding by viewBinding(PasswordRecyclerViewBinding::bind) - private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter - private lateinit var listener: OnFragmentInteractionListener - - private val model: SearchableRepositoryViewModel by activityViewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.fab.hide() - recyclerAdapter = PasswordItemRecyclerAdapter() - .onItemClicked { _, item -> - listener.onFragmentInteraction(item) - } - binding.passRecycler.apply { - layoutManager = LinearLayoutManager(requireContext()) - itemAnimator = null - adapter = recyclerAdapter - } - - FastScrollerBuilder(binding.passRecycler).build() - registerForContextMenu(binding.passRecycler) - - val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) - model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false) - model.searchResult.observe(viewLifecycleOwner) { result -> - recyclerAdapter.submitList(result.passwordItems) - } - } - - override fun onAttach(context: Context) { - super.onAttach(context) - runCatching { - listener = object : OnFragmentInteractionListener { - override fun onFragmentInteraction(item: PasswordItem) { - if (item.type == PasswordItem.TYPE_CATEGORY) { - model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly) - (requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - } - } - }.onFailure { - throw ClassCastException("$context must implement OnFragmentInteractionListener") - } - } - - val currentDir: File - get() = model.currentDir.value!! - - interface OnFragmentInteractionListener { - - fun onFragmentInteraction(item: PasswordItem) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt b/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt deleted file mode 100644 index 66873426..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/UserPreference.kt +++ /dev/null @@ -1,673 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.content.SharedPreferences -import android.content.pm.ShortcutManager -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.os.Environment -import android.provider.DocumentsContract -import android.provider.Settings -import android.text.TextUtils -import android.view.MenuItem -import android.widget.Toast -import androidx.activity.result.contract.ActivityResultContracts.OpenDocument -import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.widget.AppCompatTextView -import androidx.core.content.edit -import androidx.core.content.getSystemService -import androidx.documentfile.provider.DocumentFile -import androidx.preference.CheckBoxPreference -import androidx.preference.EditTextPreference -import androidx.preference.ListPreference -import androidx.preference.Preference -import androidx.preference.PreferenceFragmentCompat -import androidx.preference.SwitchPreferenceCompat -import com.github.ajalt.timberkt.Timber.tag -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.w -import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel -import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel -import com.github.michaelbull.result.getOr -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.crypto.BasePgpActivity -import com.zeapo.pwdstore.git.GitConfigActivity -import com.zeapo.pwdstore.git.GitServerConfigActivity -import com.zeapo.pwdstore.git.sshj.SshKey -import com.zeapo.pwdstore.pwgenxkpwd.XkpwdDictionary -import com.zeapo.pwdstore.sshkeygen.ShowSshKeyFragment -import com.zeapo.pwdstore.sshkeygen.SshKeyGenActivity -import com.zeapo.pwdstore.ui.proxy.ProxySelectorActivity -import com.zeapo.pwdstore.utils.BiometricAuthenticator -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.autofillManager -import com.zeapo.pwdstore.utils.getEncryptedGitPrefs -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.File - -typealias ClickListener = Preference.OnPreferenceClickListener -typealias ChangeListener = Preference.OnPreferenceChangeListener - -class UserPreference : AppCompatActivity() { - - private lateinit var prefsFragment: PrefsFragment - private var fromIntent = false - - @Suppress("DEPRECATION") - private val directorySelectAction = registerForActivityResult(OpenDocumentTree()) { uri: Uri? -> - if (uri == null) return@registerForActivityResult - - tag(TAG).d { "Selected repository URI is $uri" } - // TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile - val docId = DocumentsContract.getTreeDocumentId(uri) - val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - val path = if (split.size > 1) split[1] else split[0] - val repoPath = "${Environment.getExternalStorageDirectory()}/$path" - val prefs = sharedPrefs - - tag(TAG).d { "Selected repository path is $repoPath" } - - if (Environment.getExternalStorageDirectory().path == repoPath) { - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.sdcard_root_warning_title)) - .setMessage(getString(R.string.sdcard_root_warning_message)) - .setPositiveButton("Remove everything") { _, _ -> - prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) } - } - .setNegativeButton(R.string.dialog_cancel, null) - .show() - } - prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) } - if (fromIntent) { - setResult(RESULT_OK) - finish() - } - - } - - private val sshKeyImportAction = registerForActivityResult(OpenDocument()) { uri: Uri? -> - if (uri == null) return@registerForActivityResult - runCatching { - SshKey.import(uri) - - Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show() - setResult(RESULT_OK) - finish() - }.onFailure { e -> - MaterialAlertDialogBuilder(this) - .setTitle(resources.getString(R.string.ssh_key_error_dialog_title)) - .setMessage(e.message) - .setPositiveButton(resources.getString(R.string.dialog_ok), null) - .show() - } - } - - private val storeExportAction = registerForActivityResult(object : OpenDocumentTree() { - override fun createIntent(context: Context, input: Uri?): Intent { - return super.createIntent(context, input).apply { - flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or - Intent.FLAG_GRANT_PREFIX_URI_PERMISSION - } - } - }) { uri: Uri? -> - if (uri == null) return@registerForActivityResult - val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri) - - if (targetDirectory != null) { - val service = Intent(applicationContext, PasswordExportService::class.java).apply { - action = PasswordExportService.ACTION_EXPORT_PASSWORD - putExtra("uri", uri) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(service) - } else { - startService(service) - } - } - } - - private val storeCustomXkpwdDictionaryAction = registerForActivityResult(OpenDocument()) { uri -> - if (uri == null) return@registerForActivityResult - - Toast.makeText( - this, - this.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path), - Toast.LENGTH_SHORT - ).show() - - sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) } - - val customDictPref = prefsFragment.findPreference(PreferenceKeys.PREF_KEY_CUSTOM_DICT) - setCustomDictSummary(customDictPref, uri) - // copy user selected file to internal storage - val inputStream = contentResolver.openInputStream(uri) - val customDictFile = File(filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream() - inputStream?.copyTo(customDictFile, 1024) - inputStream?.close() - customDictFile.close() - - setResult(RESULT_OK) - } - - class PrefsFragment : PreferenceFragmentCompat() { - - private var autoFillEnablePreference: SwitchPreferenceCompat? = null - private var clearSavedPassPreference: Preference? = null - private var viewSshKeyPreference: Preference? = null - private lateinit var oreoAutofillDependencies: List - private lateinit var prefsActivity: UserPreference - private lateinit var sharedPreferences: SharedPreferences - private lateinit var encryptedPreferences: SharedPreferences - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - prefsActivity = requireActivity() as UserPreference - val context = requireContext() - sharedPreferences = preferenceManager.sharedPreferences - encryptedPreferences = requireActivity().getEncryptedGitPrefs() - - addPreferencesFromResource(R.xml.preference) - - // Git preferences - val gitServerPreference = findPreference(PreferenceKeys.GIT_SERVER_INFO) - val openkeystoreIdPreference = findPreference(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) - val gitConfigPreference = findPreference(PreferenceKeys.GIT_CONFIG) - val sshKeyPreference = findPreference(PreferenceKeys.SSH_KEY) - val sshKeygenPreference = findPreference(PreferenceKeys.SSH_KEYGEN) - viewSshKeyPreference = findPreference(PreferenceKeys.SSH_SEE_KEY) - clearSavedPassPreference = findPreference(PreferenceKeys.CLEAR_SAVED_PASS) - val deleteRepoPreference = findPreference(PreferenceKeys.GIT_DELETE_REPO) - val externalGitRepositoryPreference = findPreference(PreferenceKeys.GIT_EXTERNAL) - val selectExternalGitRepositoryPreference = findPreference(PreferenceKeys.PREF_SELECT_EXTERNAL) - - if (!PasswordRepository.isGitRepo()) { - listOfNotNull( - gitServerPreference, - gitConfigPreference, - sshKeyPreference, - viewSshKeyPreference, - clearSavedPassPreference, - ).forEach { - it.parent?.removePreference(it) - } - } - - // General preferences - val showTimePreference = findPreference(PreferenceKeys.GENERAL_SHOW_TIME) - val clearClipboard20xPreference = findPreference(PreferenceKeys.CLEAR_CLIPBOARD_20X) - - // Autofill preferences - autoFillEnablePreference = findPreference(PreferenceKeys.AUTOFILL_ENABLE) - val oreoAutofillDirectoryStructurePreference = findPreference(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE) - val oreoAutofillDefaultUsername = findPreference(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) - val oreoAutofillCustomPublixSuffixes = findPreference(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) - oreoAutofillDependencies = listOfNotNull( - oreoAutofillDirectoryStructurePreference, - oreoAutofillDefaultUsername, - oreoAutofillCustomPublixSuffixes, - ) - oreoAutofillCustomPublixSuffixes?.apply { - setOnBindEditTextListener { - it.isSingleLine = false - it.setHint(R.string.preference_custom_public_suffixes_hint) - } - } - - // Misc preferences - val appVersionPreference = findPreference(PreferenceKeys.APP_VERSION) - - selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) - ?: getString(R.string.no_repo_selected) - deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) - clearClipboard20xPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toInt() != 0 - openkeystoreIdPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() - ?: false - - updateAutofillSettings() - updateClearSavedPassphrasePrefs() - - appVersionPreference?.summary = "Version: ${BuildConfig.VERSION_NAME}" - - sshKeyPreference?.onPreferenceClickListener = ClickListener { - prefsActivity.getSshKey() - true - } - - sshKeygenPreference?.onPreferenceClickListener = ClickListener { - prefsActivity.makeSshKey(true) - true - } - - viewSshKeyPreference?.onPreferenceClickListener = ClickListener { - val df = ShowSshKeyFragment() - df.show(parentFragmentManager, "public_key") - true - } - - clearSavedPassPreference?.onPreferenceClickListener = ClickListener { - encryptedPreferences.edit { - if (encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD) != null) - remove(PreferenceKeys.HTTPS_PASSWORD) - else if (encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) != null) - remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) - } - updateClearSavedPassphrasePrefs() - true - } - - openkeystoreIdPreference?.onPreferenceClickListener = ClickListener { - sharedPreferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) } - it.isVisible = false - true - } - - gitServerPreference?.onPreferenceClickListener = ClickListener { - startActivity(Intent(prefsActivity, GitServerConfigActivity::class.java)) - true - } - - gitConfigPreference?.onPreferenceClickListener = ClickListener { - startActivity(Intent(prefsActivity, GitConfigActivity::class.java)) - true - } - - deleteRepoPreference?.onPreferenceClickListener = ClickListener { - val repoDir = PasswordRepository.getRepositoryDirectory() - MaterialAlertDialogBuilder(prefsActivity) - .setTitle(R.string.pref_dialog_delete_title) - .setMessage(resources.getString(R.string.dialog_delete_msg, repoDir)) - .setCancelable(false) - .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ -> - runCatching { - PasswordRepository.getRepositoryDirectory().deleteRecursively() - PasswordRepository.closeRepository() - }.onFailure { - // TODO Handle the different cases of exceptions - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - requireContext().getSystemService()?.apply { - removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) - } - } - sharedPreferences.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) } - dialogInterface.cancel() - prefsActivity.finish() - } - .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> run { dialogInterface.cancel() } } - .show() - - true - } - - selectExternalGitRepositoryPreference?.summary = - sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) - ?: context.getString(R.string.no_repo_selected) - selectExternalGitRepositoryPreference?.onPreferenceClickListener = ClickListener { - prefsActivity.selectExternalGitRepository() - true - } - - val resetRepo = Preference.OnPreferenceChangeListener { _, o -> - deleteRepoPreference?.isVisible = !(o as Boolean) - PasswordRepository.closeRepository() - sharedPreferences.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) } - true - } - - selectExternalGitRepositoryPreference?.onPreferenceChangeListener = resetRepo - externalGitRepositoryPreference?.onPreferenceChangeListener = resetRepo - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - autoFillEnablePreference?.onPreferenceClickListener = ClickListener { - onEnableAutofillClick() - true - } - } - - findPreference(PreferenceKeys.EXPORT_PASSWORDS)?.apply { - isVisible = sharedPreferences.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) - onPreferenceClickListener = Preference.OnPreferenceClickListener { - prefsActivity.exportPasswords() - true - } - } - - showTimePreference?.onPreferenceChangeListener = ChangeListener { _, newValue: Any? -> - runCatching { - val isEnabled = newValue.toString().toInt() != 0 - clearClipboard20xPreference?.isVisible = isEnabled - true - }.getOr(false) - } - - showTimePreference?.summaryProvider = Preference.SummaryProvider { - getString(R.string.pref_clipboard_timeout_summary, sharedPreferences.getString - (PreferenceKeys.GENERAL_SHOW_TIME, "45")) - } - - findPreference(PreferenceKeys.ENABLE_DEBUG_LOGGING)?.isVisible = !BuildConfig.ENABLE_DEBUG_FEATURES - - findPreference(PreferenceKeys.BIOMETRIC_AUTH)?.apply { - val canAuthenticate = BiometricAuthenticator.canAuthenticate(prefsActivity) - - if (!canAuthenticate) { - isEnabled = false - isChecked = false - summary = getString(R.string.biometric_auth_summary_error) - } else { - setOnPreferenceClickListener { - isEnabled = false - sharedPreferences.edit { - val checked = isChecked - BiometricAuthenticator.authenticate(requireActivity()) { result -> - when (result) { - is BiometricAuthenticator.Result.Success -> { - // Apply the changes - putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked) - isEnabled = true - } - else -> { - // If any error occurs, revert back to the previous state. This - // catch-all clause includes the cancellation case. - putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked) - isChecked = !checked - isEnabled = true - } - } - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { - requireContext().getSystemService()?.apply { - removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) - } - } - } - true - } - } - } - - findPreference(PreferenceKeys.PROXY_SETTINGS)?.onPreferenceClickListener = ClickListener { - startActivity(Intent(requireContext(), ProxySelectorActivity::class.java)) - true - } - - val prefCustomXkpwdDictionary = findPreference(PreferenceKeys.PREF_KEY_CUSTOM_DICT) - prefCustomXkpwdDictionary?.onPreferenceClickListener = ClickListener { - prefsActivity.storeCustomDictionaryPath() - true - } - val dictUri = sharedPreferences.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: "" - - if (!TextUtils.isEmpty(dictUri)) { - setCustomDictSummary(prefCustomXkpwdDictionary, Uri.parse(dictUri)) - } - - val prefIsCustomDict = findPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT) - val prefCustomDictPicker = findPreference(PreferenceKeys.PREF_KEY_CUSTOM_DICT) - val prefPwgenType = findPreference(PreferenceKeys.PREF_KEY_PWGEN_TYPE) - updateXkPasswdPrefsVisibility(prefPwgenType?.value, prefIsCustomDict, prefCustomDictPicker) - - prefPwgenType?.onPreferenceChangeListener = ChangeListener { _, newValue -> - updateXkPasswdPrefsVisibility(newValue, prefIsCustomDict, prefCustomDictPicker) - true - } - - prefIsCustomDict?.onPreferenceChangeListener = ChangeListener { _, newValue -> - if (!(newValue as Boolean)) { - val customDictFile = File(context.filesDir, XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE) - if (customDictFile.exists() && !customDictFile.delete()) { - w { "Failed to delete custom XkPassword dictionary: $customDictFile" } - } - prefCustomDictPicker?.setSummary(R.string.xkpwgen_pref_custom_dict_picker_summary) - } - true - } - } - - private fun updateXkPasswdPrefsVisibility(newValue: Any?, prefIsCustomDict: CheckBoxPreference?, prefCustomDictPicker: Preference?) { - when (newValue as String) { - BasePgpActivity.KEY_PWGEN_TYPE_CLASSIC -> { - prefIsCustomDict?.isVisible = false - prefCustomDictPicker?.isVisible = false - } - BasePgpActivity.KEY_PWGEN_TYPE_XKPASSWD -> { - prefIsCustomDict?.isVisible = true - prefCustomDictPicker?.isVisible = true - } - } - } - - private fun updateAutofillSettings() { - val isAutofillServiceEnabled = prefsActivity.isAutofillServiceEnabled - val isAutofillSupported = prefsActivity.isAutofillServiceSupported - if (!isAutofillSupported) { - autoFillEnablePreference?.isVisible = false - } else { - autoFillEnablePreference?.isChecked = isAutofillServiceEnabled - } - oreoAutofillDependencies.forEach { - it.isVisible = isAutofillServiceEnabled - } - } - - private fun updateClearSavedPassphrasePrefs() { - clearSavedPassPreference?.apply { - val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) - val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD) - if (sshPass == null && httpsPass == null) { - isVisible = false - return@apply - } - title = when { - httpsPass != null -> getString(R.string.clear_saved_passphrase_https) - sshPass != null -> getString(R.string.clear_saved_passphrase_ssh) - else -> null - } - isVisible = true - } - } - - private fun updateViewSshPubkeyPref() { - viewSshKeyPreference?.isVisible = SshKey.canShowSshPublicKey - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun onEnableAutofillClick() { - if (prefsActivity.isAutofillServiceEnabled) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - prefsActivity.autofillManager!!.disableAutofillServices() - else - throw IllegalStateException("isAutofillServiceEnabled == true, but Build.VERSION.SDK_INT < Build.VERSION_CODES.O") - } else { - MaterialAlertDialogBuilder(prefsActivity).run { - setTitle(R.string.pref_autofill_enable_title) - @SuppressLint("InflateParams") - val layout = - layoutInflater.inflate(R.layout.oreo_autofill_instructions, null) - val supportedBrowsersTextView = - layout.findViewById(R.id.supportedBrowsers) - supportedBrowsersTextView.text = - getInstalledBrowsersWithAutofillSupportLevel(context).joinToString( - separator = "\n" - ) { - val appLabel = it.first - val supportDescription = when (it.second) { - BrowserAutofillSupportLevel.None -> getString(R.string.oreo_autofill_no_support) - BrowserAutofillSupportLevel.FlakyFill -> getString(R.string.oreo_autofill_flaky_fill_support) - BrowserAutofillSupportLevel.PasswordFill -> getString(R.string.oreo_autofill_password_fill_support) - BrowserAutofillSupportLevel.GeneralFill -> getString(R.string.oreo_autofill_general_fill_support) - BrowserAutofillSupportLevel.GeneralFillAndSave -> getString(R.string.oreo_autofill_general_fill_and_save_support) - } - "$appLabel: $supportDescription" - } - setView(layout) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { - data = Uri.parse("package:${BuildConfig.APPLICATION_ID}") - } - startActivity(intent) - } - setNegativeButton(R.string.dialog_cancel, null) - setOnDismissListener { updateAutofillSettings() } - show() - } - } - } - - override fun onResume() { - super.onResume() - updateAutofillSettings() - updateClearSavedPassphrasePrefs() - updateViewSshPubkeyPref() - } - } - - override fun onBackPressed() { - super.onBackPressed() - setResult(RESULT_OK) - finish() - } - - public override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - when (intent?.getStringExtra("operation")) { - "get_ssh_key" -> getSshKey() - "make_ssh_key" -> makeSshKey(false) - "git_external" -> { - fromIntent = true - selectExternalGitRepository() - } - } - prefsFragment = PrefsFragment() - - supportFragmentManager - .beginTransaction() - .replace(android.R.id.content, prefsFragment) - .commit() - - supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - - @Suppress("Deprecation") // for Environment.getExternalStorageDirectory() - fun selectExternalGitRepository() { - MaterialAlertDialogBuilder(this) - .setTitle(this.resources.getString(R.string.external_repository_dialog_title)) - .setMessage(this.resources.getString(R.string.external_repository_dialog_text)) - .setPositiveButton(R.string.dialog_ok) { _, _ -> - directorySelectAction.launch(null) - } - .setNegativeButton(R.string.dialog_cancel, null) - .show() - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - // Handle action bar item clicks here. The action bar will - // automatically handle clicks on the Home/Up button, so long - // as you specify a parent activity in AndroidManifest.xml. - return when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_OK) - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun importSshKey() { - sshKeyImportAction.launch(arrayOf("*/*")) - } - - /** - * Opens a file explorer to import the private key - */ - private fun getSshKey() { - if (SshKey.exists) { - MaterialAlertDialogBuilder(this).run { - setTitle(R.string.ssh_keygen_existing_title) - setMessage(R.string.ssh_keygen_existing_message) - setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> - importSshKey() - } - setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> } - show() - } - } else { - importSshKey() - } - } - - /** - * Exports the passwords - */ - private fun exportPasswords() { - storeExportAction.launch(null) - } - - /** - * Opens a key generator to generate a public/private key pair - */ - fun makeSshKey(fromPreferences: Boolean) { - val intent = Intent(applicationContext, SshKeyGenActivity::class.java) - startActivity(intent) - if (!fromPreferences) { - setResult(RESULT_OK) - finish() - } - } - - /** - * Pick custom xkpwd dictionary from sdcard - */ - private fun storeCustomDictionaryPath() { - storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*")) - } - - private val isAutofillServiceSupported: Boolean - get() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false - return autofillManager?.isAutofillSupported != null - } - - private val isAutofillServiceEnabled: Boolean - get() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false - return autofillManager?.hasEnabledAutofillServices() == true - } - - companion object { - - private const val TAG = "UserPreference" - - fun createDirectorySelectionIntent(context: Context): Intent { - return Intent(context, UserPreference::class.java).run { - putExtra("operation", "git_external") - } - } - - /** - * Set custom dictionary summary - */ - @JvmStatic - private fun setCustomDictSummary(customDictPref: Preference?, uri: Uri) { - val fileName = uri.path?.substring(uri.path?.lastIndexOf(":")!! + 1) - customDictPref?.summary = "Selected dictionary: $fileName" - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Api30AutofillResponseBuilder.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Api30AutofillResponseBuilder.kt deleted file mode 100644 index a69e51a7..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/Api30AutofillResponseBuilder.kt +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.autofill.oreo - -import android.content.Context -import android.content.IntentSender -import android.os.Build -import android.service.autofill.Dataset -import android.service.autofill.FillCallback -import android.service.autofill.FillResponse -import android.service.autofill.SaveInfo -import android.view.inputmethod.InlineSuggestionsRequest -import android.widget.inline.InlinePresentationSpec -import androidx.annotation.RequiresApi -import com.github.ajalt.timberkt.e -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.FillableForm -import com.github.androidpasswordstore.autofillparser.fillWith -import com.github.michaelbull.result.fold -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillDecryptActivity -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillPublisherChangedActivity -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSmsActivity -import java.io.File - -/** - * Implements [AutofillResponseBuilder]'s methods for API 30 and above - */ -@RequiresApi(Build.VERSION_CODES.R) -class Api30AutofillResponseBuilder(form: FillableForm) { - - private val formOrigin = form.formOrigin - private val scenario = form.scenario - private val ignoredIds = form.ignoredIds - private val saveFlags = form.saveFlags - private val clientState = form.toClientState() - - // We do not offer save when the only relevant field is a username field or there is no field. - private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave - private val canBeSaved = saveFlags != null && scenarioSupportsSave - - private fun makeIntentDataset( - context: Context, - action: AutofillAction, - intentSender: IntentSender, - metadata: DatasetMetadata, - imeSpec: InlinePresentationSpec?, - ): Dataset { - return Dataset.Builder(makeRemoteView(context, metadata)).run { - fillWith(scenario, action, credentials = null) - setAuthentication(intentSender) - if (imeSpec != null) { - val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata) - if (inlinePresentation != null) { - setInlinePresentation(inlinePresentation) - } - } - build() - } - } - - private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null - val metadata = makeFillMatchMetadata(context, file) - val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) - } - - private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null - val metadata = makeSearchAndFillMetadata(context) - val intentSender = - AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) - return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec) - } - - private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null - val metadata = makeGenerateAndFillMetadata(context) - val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) - return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec) - } - - - private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null - if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null - val metadata = makeFillOtpFromSmsMetadata(context) - val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) - return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec) - } - - private fun makePublisherChangedDataset( - context: Context, - publisherChangedException: AutofillPublisherChangedException, - imeSpec: InlinePresentationSpec? - ): Dataset { - val metadata = makeWarningMetadata(context) - // If the user decides to trust the new publisher, they can choose reset the list of - // matches. In this case we need to immediately show a new `FillResponse` as if the app were - // autofilled for the first time. This `FillResponse` needs to be returned as a result from - // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. - val fillResponseAfterReset = makeFillResponse(context, null, emptyList()) - val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender( - context, publisherChangedException, fillResponseAfterReset - ) - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) - } - - private fun makePublisherChangedResponse( - context: Context, - inlineSuggestionsRequest: InlineSuggestionsRequest?, - publisherChangedException: AutofillPublisherChangedException - ): FillResponse { - val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull() - return FillResponse.Builder().run { - addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec)) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List): FillResponse? { - var datasetCount = 0 - val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList() - return FillResponse.Builder().run { - for (file in matchedFiles) { - makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - } - makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let { - datasetCount++ - addDataset(it) - } - if (datasetCount == 0) return null - setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))) - makeSaveInfo()?.let { setSaveInfo(it) } - setClientState(clientState) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE - // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE - private fun makeSaveInfo(): SaveInfo? { - if (!canBeSaved) return null - check(saveFlags != null) - val idsToSave = scenario.fieldsToSave.toTypedArray() - if (idsToSave.isEmpty()) return null - var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD - if (scenario.hasUsername) { - saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME - } - return SaveInfo.Builder(saveDataTypes, idsToSave).run { - setFlags(saveFlags) - build() - } - } - - /** - * Creates and returns a suitable [FillResponse] to the Autofill framework. - */ - fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) { - AutofillMatcher.getMatchesFor(context, formOrigin).fold( - success = { matchedFiles -> - callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles)) - }, - failure = { e -> - e(e) - callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e)) - } - ) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt deleted file mode 100644 index 601be34a..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillMatcher.kt +++ /dev/null @@ -1,193 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -import android.content.Context -import android.content.SharedPreferences -import android.widget.Toast -import androidx.core.content.edit -import com.github.ajalt.timberkt.Timber.e -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.w -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import com.github.androidpasswordstore.autofillparser.FormOrigin -import com.github.androidpasswordstore.autofillparser.computeCertificatesHash -import com.zeapo.pwdstore.R -import java.io.File - -private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches" -private val Context.autofillAppMatches - get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE) - -private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches" -private val Context.autofillWebMatches - get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE) - -private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences { - return when (formOrigin) { - is FormOrigin.App -> autofillAppMatches - is FormOrigin.Web -> autofillWebMatches - } -} - -class AutofillPublisherChangedException(val formOrigin: FormOrigin) : - Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") { - - init { - require(formOrigin is FormOrigin.App) - } -} - -/** - * Manages "matches", i.e., associations between apps or websites and Password Store entries. - */ -class AutofillMatcher { - - companion object { - - private const val MAX_NUM_MATCHES = 10 - - private const val PREFERENCE_PREFIX_TOKEN = "token;" - private fun tokenKey(formOrigin: FormOrigin.App) = - "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}" - - private const val PREFERENCE_PREFIX_MATCHES = "matches;" - private fun matchesKey(formOrigin: FormOrigin) = - "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}" - - private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean { - return when (formOrigin) { - is FormOrigin.Web -> false - is FormOrigin.App -> { - val packageName = formOrigin.identifier - val certificatesHash = computeCertificatesHash(context, packageName) - val storedCertificatesHash = - context.autofillAppMatches.getString(tokenKey(formOrigin), null) - ?: return false - val hashHasChanged = certificatesHash != storedCertificatesHash - if (hashHasChanged) { - e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" } - true - } else { - false - } - } - } - } - - private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) { - if (formOrigin is FormOrigin.App) { - val packageName = formOrigin.identifier - val certificatesHash = computeCertificatesHash(context, packageName) - context.autofillAppMatches.edit { - putString(tokenKey(formOrigin), certificatesHash) - } - } - // We don't need to store a hash for FormOrigin.Web since it can only originate from - // browsers we trust to verify the origin. - } - - /** - * Get all Password Store entries that have already been associated with [formOrigin] by the - * user. - * - * If [formOrigin] represents an app and that app's certificates have changed since the - * first time the user associated an entry with it, an [AutofillPublisherChangedException] - * will be thrown. - */ - fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result, AutofillPublisherChangedException> { - if (hasFormOriginHashChanged(context, formOrigin)) { - return Err(AutofillPublisherChangedException(formOrigin)) - } - val matchPreferences = context.matchPreferences(formOrigin) - val matchedFiles = - matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } - return Ok(matchedFiles.filter { it.exists() }.also { validFiles -> - matchPreferences.edit { - putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) - } - }) - } - - fun clearMatchesFor(context: Context, formOrigin: FormOrigin) { - context.matchPreferences(formOrigin).edit { - remove(matchesKey(formOrigin)) - if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin)) - } - } - - /** - * Associates the store entry [file] with [formOrigin], such that future Autofill responses - * to requests from this app or website offer this entry as a dataset. - * - * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of - * Android may crash when too many datasets are offered. - */ - fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) { - if (!file.exists()) return - if (hasFormOriginHashChanged(context, formOrigin)) { - // This should never happen since we already verified the publisher in - // getMatchesFor. - e { "App publisher changed between getMatchesFor and addMatchFor" } - throw AutofillPublisherChangedException(formOrigin) - } - val matchPreferences = context.matchPreferences(formOrigin) - val matchedFiles = - matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } - val newFiles = setOf(file.absoluteFile).union(matchedFiles) - if (newFiles.size > MAX_NUM_MATCHES) { - Toast.makeText( - context, - context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES), - Toast.LENGTH_LONG - ).show() - return - } - matchPreferences.edit { - putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) - } - storeFormOriginHash(context, formOrigin) - d { "Stored match for $formOrigin" } - } - - /** - * Goes through all existing matches and updates their associated entries by using - * [moveFromTo] as a lookup table and deleting the matches for files in [delete]. - */ - fun updateMatches(context: Context, moveFromTo: Map = 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) { - w { "Failed to read matches for $key" } - continue - } - // Delete all matches for file locations that are going to be overwritten, then - // transfer matches over to the files at their new locations. - val newMatches = - oldMatches.asSequence() - .minus(deletePathList) - .minus(oldNewPathMap.values) - .map { match -> - val newPath = oldNewPathMap[match] ?: return@map match - d { "Updating match for $key: $match --> $newPath" } - newPath - }.toSet() - if (newMatches != oldMatches) - prefs.edit { putStringSet(key, newMatches) } - } - } - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt deleted file mode 100644 index cc0875f3..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillPreferences.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -import android.content.Context -import android.os.Build -import androidx.annotation.RequiresApi -import com.github.androidpasswordstore.autofillparser.Credentials -import com.zeapo.pwdstore.model.PasswordEntry -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.File -import java.nio.file.Paths - -enum class DirectoryStructure(val value: String) { - EncryptedUsername("encrypted_username"), - FileBased("file"), - DirectoryBased("directory"); - - /** - * Returns the username associated to [file], following the convention of the current - * [DirectoryStructure]. - * - * Examples: - * - * --> null (EncryptedUsername) - * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) - * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased) - * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) - */ - fun getUsernameFor(file: File): String? = when (this) { - EncryptedUsername -> null - FileBased -> file.nameWithoutExtension - DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension - } - - /** - * Returns the origin identifier associated to [file], following the convention of the current - * [DirectoryStructure]. - * - * At least one of [DirectoryStructure.getIdentifierFor] and - * [DirectoryStructure.getAccountPartFor] will always return a non-null result. - * - * Examples: - * - work/example.org.gpg --> example.org (EncryptedUsername) - * - work/example.org/john@doe.org.gpg --> example.org (FileBased) - * - example.org.gpg --> example.org (FileBased, fallback) - * - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased) - * - Temporary PIN.gpg --> null (DirectoryBased) - */ - fun getIdentifierFor(file: File): String? = when (this) { - EncryptedUsername -> file.nameWithoutExtension - FileBased -> file.parentFile?.name ?: file.nameWithoutExtension - DirectoryBased -> file.parentFile?.parent - } - - /** - * Returns the path components of [file] until right before the component that contains the - * origin identifier according to the current [DirectoryStructure]. - * - * Examples: - * - work/example.org.gpg --> work (EncryptedUsername) - * - work/example.org/john@doe.org.gpg --> work (FileBased) - * - example.org/john@doe.org.gpg --> null (FileBased) - * - john@doe.org.gpg --> null (FileBased) - * - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased) - * - example.org/john@doe.org/password.gpg --> null (DirectoryBased) - */ - fun getPathToIdentifierFor(file: File): String? = when (this) { - EncryptedUsername -> file.parent - FileBased -> file.parentFile?.parent - DirectoryBased -> file.parentFile?.parentFile?.parent - } - - /** - * Returns the path component of [file] following the origin identifier according to the current - * [DirectoryStructure] (without file extension). - * - * At least one of [DirectoryStructure.getIdentifierFor] and - * [DirectoryStructure.getAccountPartFor] will always return a non-null result. - * - * Examples: - * - * --> null (EncryptedUsername) - * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) - * - example.org.gpg --> null (FileBased, fallback) - * - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased) - * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) - */ - fun getAccountPartFor(file: File): String? = when (this) { - EncryptedUsername -> null - FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null } - DirectoryBased -> file.parentFile?.let { parentFile -> - "${parentFile.name}/${file.nameWithoutExtension}" - } ?: file.nameWithoutExtension - } - - @RequiresApi(Build.VERSION_CODES.O) - fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) { - EncryptedUsername -> "/" - FileBased -> sanitizedIdentifier - DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString() - } - - fun getSaveFileName(username: String?, identifier: String) = when (this) { - EncryptedUsername -> identifier - FileBased -> username - DirectoryBased -> "password" - } - - companion object { - - const val PREFERENCE = "oreo_autofill_directory_structure" - private val DEFAULT = FileBased - - private val reverseMap = values().associateBy { it.value } - fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT - } -} - -object AutofillPreferences { - - fun directoryStructure(context: Context): DirectoryStructure { - val value = context.sharedPrefs.getString(DirectoryStructure.PREFERENCE, null) - return DirectoryStructure.fromValue(value) - } - - fun credentialsFromStoreEntry( - context: Context, - file: File, - entry: PasswordEntry, - directoryStructure: DirectoryStructure - ): Credentials { - // Always give priority to a username stored in the encrypted extras - val username = entry.username - ?: directoryStructure.getUsernameFor(file) - ?: context.getDefaultUsername() - return Credentials(username, entry.password, entry.calculateTotpCode()) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt deleted file mode 100644 index d73373e3..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt +++ /dev/null @@ -1,203 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -import android.content.Context -import android.content.IntentSender -import android.os.Build -import android.os.Bundle -import android.service.autofill.Dataset -import android.service.autofill.FillCallback -import android.service.autofill.FillResponse -import android.service.autofill.SaveInfo -import androidx.annotation.RequiresApi -import com.github.ajalt.timberkt.e -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.AutofillScenario -import com.github.androidpasswordstore.autofillparser.Credentials -import com.github.androidpasswordstore.autofillparser.FillableForm -import com.github.androidpasswordstore.autofillparser.fillWith -import com.github.michaelbull.result.fold -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillDecryptActivity -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillPublisherChangedActivity -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSmsActivity -import java.io.File - -@RequiresApi(Build.VERSION_CODES.O) -class AutofillResponseBuilder(form: FillableForm) { - private val formOrigin = form.formOrigin - private val scenario = form.scenario - private val ignoredIds = form.ignoredIds - private val saveFlags = form.saveFlags - private val clientState = form.toClientState() - - // We do not offer save when the only relevant field is a username field or there is no field. - private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave - private val canBeSaved = saveFlags != null && scenarioSupportsSave - - private fun makeIntentDataset( - context: Context, - action: AutofillAction, - intentSender: IntentSender, - metadata: DatasetMetadata, - ): Dataset { - return Dataset.Builder(makeRemoteView(context, metadata)).run { - fillWith(scenario, action, credentials = null) - setAuthentication(intentSender) - build() - } - } - - private fun makeMatchDataset(context: Context, file: File): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null - val metadata = makeFillMatchMetadata(context, file) - val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) - } - - - private fun makeSearchDataset(context: Context): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null - val metadata = makeSearchAndFillMetadata(context) - val intentSender = - AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) - return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata) - } - - private fun makeGenerateDataset(context: Context): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null - val metadata = makeGenerateAndFillMetadata(context) - val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) - return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata) - } - - private fun makeFillOtpFromSmsDataset(context: Context): Dataset? { - if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null - if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null - val metadata = makeFillOtpFromSmsMetadata(context) - val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) - return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata) - } - - private fun makePublisherChangedDataset( - context: Context, - publisherChangedException: AutofillPublisherChangedException, - ): Dataset { - val metadata = makeWarningMetadata(context) - // If the user decides to trust the new publisher, they can choose reset the list of - // matches. In this case we need to immediately show a new `FillResponse` as if the app were - // autofilled for the first time. This `FillResponse` needs to be returned as a result from - // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. - val fillResponseAfterReset = makeFillResponse(context, emptyList()) - val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender( - context, publisherChangedException, fillResponseAfterReset - ) - return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) - } - - private fun makePublisherChangedResponse( - context: Context, - publisherChangedException: AutofillPublisherChangedException - ): FillResponse { - return FillResponse.Builder().run { - addDataset(makePublisherChangedDataset(context, publisherChangedException)) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE - // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE - private fun makeSaveInfo(): SaveInfo? { - if (!canBeSaved) return null - check(saveFlags != null) - val idsToSave = scenario.fieldsToSave.toTypedArray() - if (idsToSave.isEmpty()) return null - var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD - if (scenario.hasUsername) { - saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME - } - return SaveInfo.Builder(saveDataTypes, idsToSave).run { - setFlags(saveFlags) - build() - } - } - - private fun makeFillResponse(context: Context, matchedFiles: List): FillResponse? { - var datasetCount = 0 - return FillResponse.Builder().run { - for (file in matchedFiles) { - makeMatchDataset(context, file)?.let { - datasetCount++ - addDataset(it) - } - } - makeSearchDataset(context)?.let { - datasetCount++ - addDataset(it) - } - makeGenerateDataset(context)?.let { - datasetCount++ - addDataset(it) - } - makeFillOtpFromSmsDataset(context)?.let { - datasetCount++ - addDataset(it) - } - if (datasetCount == 0) return null - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))) - } - makeSaveInfo()?.let { setSaveInfo(it) } - setClientState(clientState) - setIgnoredIds(*ignoredIds.toTypedArray()) - build() - } - } - - /** - * Creates and returns a suitable [FillResponse] to the Autofill framework. - */ - fun fillCredentials(context: Context, callback: FillCallback) { - AutofillMatcher.getMatchesFor(context, formOrigin).fold( - success = { matchedFiles -> - callback.onSuccess(makeFillResponse(context, matchedFiles)) - }, - failure = { e -> - e(e) - callback.onSuccess(makePublisherChangedResponse(context, e)) - } - ) - } - - companion object { - fun makeFillInDataset( - context: Context, - credentials: Credentials, - clientState: Bundle, - action: AutofillAction - ): Dataset { - val scenario = AutofillScenario.fromClientState(clientState) - // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even - // though they are rarely shown. - // FIXME: We should clone the original dataset here and add the credentials to be filled - // in. Otherwise, the entry in the cached list of datasets will be overwritten by the - // fill-in dataset without any visual representation. This causes it to be missing from - // the Autofill suggestions shown after the user clears the filled out form fields. - val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - Dataset.Builder() - } else { - Dataset.Builder(makeRemoteView(context, makeEmptyMetadata())) - } - return builder.run { - if (scenario != null) fillWith(scenario, action, credentials) - else e { "Failed to recover scenario from client state" } - build() - } - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt deleted file mode 100644 index 49e0d3e3..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt +++ /dev/null @@ -1,113 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -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 com.zeapo.pwdstore.PasswordStore -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.PasswordRepository -import java.io.File - -data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int) - -fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews { - return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply { - setTextViewText(R.id.title, metadata.title) - if (metadata.subtitle != null) { - setTextViewText(R.id.summary, metadata.subtitle) - } else { - setViewVisibility(R.id.summary, View.GONE) - } - if (metadata.iconRes != Resources.ID_NULL) { - setImageViewResource(R.id.icon, metadata.iconRes) - } else { - setViewVisibility(R.id.icon, View.GONE) - } - } -} - -@SuppressLint("RestrictedApi") -fun makeInlinePresentation(context: Context, imeSpec: InlinePresentationSpec, metadata: DatasetMetadata): InlinePresentation? { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) - return null - - if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) - return null - - val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0) - val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run { - setTitle(metadata.title) - if (metadata.subtitle != null) - setSubtitle(metadata.subtitle) - setContentDescription(if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title) - setStartIcon(Icon.createWithResource(context, metadata.iconRes)) - build().slice - } - - return InlinePresentation(slice, imeSpec, false) -} - - -fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata { - val directoryStructure = AutofillPreferences.directoryStructure(context) - val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) - val title = directoryStructure.getIdentifierFor(relativeFile) - ?: directoryStructure.getAccountPartFor(relativeFile)!! - val subtitle = directoryStructure.getAccountPartFor(relativeFile) - return DatasetMetadata( - title, - subtitle, - R.drawable.ic_person_black_24dp - ) -} - -fun makeSearchAndFillMetadata(context: Context) = DatasetMetadata( - context.getString(R.string.oreo_autofill_search_in_store), - null, - R.drawable.ic_search_black_24dp -) - -fun makeGenerateAndFillMetadata(context: Context) = DatasetMetadata( - context.getString(R.string.oreo_autofill_generate_password), - null, - R.drawable.ic_autofill_new_password -) - -fun makeFillOtpFromSmsMetadata(context: Context) = DatasetMetadata( - context.getString(R.string.oreo_autofill_fill_otp_from_sms), - null, - R.drawable.ic_autofill_sms -) - -fun makeEmptyMetadata() = DatasetMetadata( - "PLACEHOLDER", - "PLACEHOLDER", - R.mipmap.ic_launcher -) - -fun makeWarningMetadata(context: Context) = DatasetMetadata( - context.getString(R.string.oreo_autofill_warning_publisher_dataset_title), - context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary), - R.drawable.ic_warning_red_24dp -) - -fun makeHeaderMetadata(title: String) = DatasetMetadata( - title, - null, - 0 -) diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt deleted file mode 100644 index cc7e2c0c..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo - -import android.content.Context -import android.os.Build -import android.os.CancellationSignal -import android.service.autofill.AutofillService -import android.service.autofill.FillCallback -import android.service.autofill.FillRequest -import android.service.autofill.FillResponse -import android.service.autofill.SaveCallback -import android.service.autofill.SaveRequest -import androidx.annotation.RequiresApi -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.e -import com.github.androidpasswordstore.autofillparser.AutofillScenario -import com.github.androidpasswordstore.autofillparser.Credentials -import com.github.androidpasswordstore.autofillparser.FillableForm -import com.github.androidpasswordstore.autofillparser.FixedSaveCallback -import com.github.androidpasswordstore.autofillparser.FormOrigin -import com.github.androidpasswordstore.autofillparser.cachePublicSuffixList -import com.github.androidpasswordstore.autofillparser.passwordValue -import com.github.androidpasswordstore.autofillparser.recoverNodes -import com.github.androidpasswordstore.autofillparser.usernameValue -import com.zeapo.pwdstore.BuildConfig -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.autofill.oreo.ui.AutofillSaveActivity -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.hasFlag -import com.zeapo.pwdstore.utils.sharedPrefs - -@RequiresApi(Build.VERSION_CODES.O) -class OreoAutofillService : AutofillService() { - - companion object { - - // TODO: Provide a user-configurable denylist - private val DENYLISTED_PACKAGES = listOf( - BuildConfig.APPLICATION_ID, - "android", - "com.android.settings", - "com.android.settings.intelligence", - "com.android.systemui", - "com.oneplus.applocker", - "org.sufficientlysecure.keychain", - ) - - private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L - } - - override fun onCreate() { - super.onCreate() - cachePublicSuffixList(applicationContext) - } - - override fun onFillRequest( - request: FillRequest, - cancellationSignal: CancellationSignal, - callback: FillCallback - ) { - val structure = request.fillContexts.lastOrNull()?.structure ?: run { - callback.onSuccess(null) - return - } - if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) { - if (Build.VERSION.SDK_INT >= 28) { - callback.onSuccess(FillResponse.Builder().run { - disableAutofill(DISABLE_AUTOFILL_DURATION_MS) - build() - }) - } else { - callback.onSuccess(null) - } - return - } - val formToFill = FillableForm.parseAssistStructure( - this, structure, - isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST, - getCustomSuffixes(), - ) ?: run { - d { "Form cannot be filled" } - callback.onSuccess(null) - return - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback) - } else { - AutofillResponseBuilder(formToFill).fillCredentials(this, callback) - } - } - - override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { - // SaveCallback's behavior and feature set differs based on both target and device SDK, so - // we replace it with a wrapper that works the same in all situations. - @Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback) - val structure = request.fillContexts.lastOrNull()?.structure ?: run { - callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported)) - return - } - val clientState = request.clientState ?: run { - e { "Received save request without client state" } - callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) - return - } - val scenario = AutofillScenario.fromClientState(clientState)?.recoverNodes(structure) ?: run { - e { "Failed to recover client state or nodes from client state" } - callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) - return - } - val formOrigin = FormOrigin.fromBundle(clientState) ?: run { - e { "Failed to recover form origin from client state" } - callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) - return - } - - val username = scenario.usernameValue - val password = scenario.passwordValue ?: run { - callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match)) - return - } - callback.onSuccess( - AutofillSaveActivity.makeSaveIntentSender( - this, - credentials = Credentials(username, password, null), - formOrigin = formOrigin - ) - ) - } -} - -fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) - -fun Context.getCustomSuffixes(): Sequence { - 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/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt deleted file mode 100644 index a5bdcfe0..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillDecryptActivity.kt +++ /dev/null @@ -1,245 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo.ui - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.IntentSender -import android.os.Build -import android.os.Bundle -import android.view.autofill.AutofillManager -import android.widget.Toast -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess -import com.github.michaelbull.result.runCatching -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.Credentials -import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences -import com.zeapo.pwdstore.autofill.oreo.AutofillResponseBuilder -import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure -import com.zeapo.pwdstore.model.PasswordEntry -import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.InputStream -import java.io.OutputStream -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.msfjarvis.openpgpktx.util.OpenPgpApi -import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection -import org.openintents.openpgp.IOpenPgpService2 -import org.openintents.openpgp.OpenPgpError - -@RequiresApi(Build.VERSION_CODES.O) -class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope { - - companion object { - - private const val EXTRA_FILE_PATH = "com.zeapo.pwdstore.autofill.oreo.EXTRA_FILE_PATH" - private const val EXTRA_SEARCH_ACTION = - "com.zeapo.pwdstore.autofill.oreo.EXTRA_SEARCH_ACTION" - - private var decryptFileRequestCode = 1 - - fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent { - return Intent(context, AutofillDecryptActivity::class.java).apply { - putExtras(forwardedExtras) - putExtra(EXTRA_SEARCH_ACTION, true) - putExtra(EXTRA_FILE_PATH, file.absolutePath) - } - } - - fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender { - val intent = Intent(context, AutofillDecryptActivity::class.java).apply { - putExtra(EXTRA_SEARCH_ACTION, false) - putExtra(EXTRA_FILE_PATH, file.absolutePath) - } - return PendingIntent.getActivity( - context, - decryptFileRequestCode++, - intent, - PendingIntent.FLAG_CANCEL_CURRENT - ).intentSender - } - } - - private val decryptInteractionRequiredAction = registerForActivityResult(StartIntentSenderForResult()) { result -> - if (continueAfterUserInteraction != null) { - val data = result.data - if (result.resultCode == RESULT_OK && data != null) { - continueAfterUserInteraction?.resume(data) - } else { - continueAfterUserInteraction?.resumeWithException(Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")) - } - continueAfterUserInteraction = null - } - } - - private var continueAfterUserInteraction: Continuation? = null - private lateinit var directoryStructure: DirectoryStructure - - override val coroutineContext - get() = Dispatchers.IO + SupervisorJob() - - override fun onStart() { - super.onStart() - val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run { - e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" } - finish() - return - } - val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { - e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } - finish() - return - } - val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! - val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match - directoryStructure = AutofillPreferences.directoryStructure(this) - d { action.toString() } - launch { - val credentials = decryptCredential(File(filePath)) - if (credentials == null) { - setResult(RESULT_CANCELED) - } else { - val fillInDataset = - AutofillResponseBuilder.makeFillInDataset( - this@AutofillDecryptActivity, - credentials, - clientState, - action - ) - withContext(Dispatchers.Main) { - setResult(RESULT_OK, Intent().apply { - putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) - }) - } - } - withContext(Dispatchers.Main) { - finish() - } - } - } - - override fun onDestroy() { - super.onDestroy() - coroutineContext.cancelChildren() - } - - private suspend fun executeOpenPgpApi( - data: Intent, - input: InputStream, - output: OutputStream - ): Intent? { - var openPgpServiceConnection: OpenPgpServiceConnection? = null - val openPgpService = suspendCoroutine { cont -> - openPgpServiceConnection = OpenPgpServiceConnection( - this, - OPENPGP_PROVIDER, - object : OpenPgpServiceConnection.OnBound { - override fun onBound(service: IOpenPgpService2) { - cont.resume(service) - } - - override fun onError(e: Exception) { - cont.resumeWithException(e) - } - }).also { it.bindToService() } - } - return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also { - openPgpServiceConnection?.unbindFromService() - } - } - - private suspend fun decryptCredential( - file: File, - resumeIntent: Intent? = null - ): Credentials? { - val command = resumeIntent ?: Intent().apply { - action = OpenPgpApi.ACTION_DECRYPT_VERIFY - } - runCatching { - file.inputStream() - }.onFailure { e -> - e(e) { "File to decrypt not found" } - return null - }.onSuccess { encryptedInput -> - val decryptedOutput = ByteArrayOutputStream() - runCatching { - executeOpenPgpApi(command, encryptedInput, decryptedOutput) - }.onFailure { e -> - e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" } - return null - }.onSuccess { result -> - return when (val resultCode = - result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - OpenPgpApi.RESULT_CODE_SUCCESS -> { - runCatching { - val entry = withContext(Dispatchers.IO) { - @Suppress("BlockingMethodInNonBlockingContext") - PasswordEntry(decryptedOutput) - } - AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure) - }.getOrElse { e -> - e(e) { "Failed to parse password entry" } - return null - } - } - OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { - val pendingIntent: PendingIntent = - result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!! - runCatching { - val intentToResume = withContext(Dispatchers.Main) { - suspendCoroutine { cont -> - continueAfterUserInteraction = cont - decryptInteractionRequiredAction.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build()) - } - } - decryptCredential(file, intentToResume) - }.getOrElse { e -> - e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" } - return null - } - } - OpenPgpApi.RESULT_CODE_ERROR -> { - val error = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) - if (error != null) { - withContext(Dispatchers.Main) { - Toast.makeText( - applicationContext, - "Error from OpenKeyChain: ${error.message}", - Toast.LENGTH_LONG - ).show() - } - e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" } - } - null - } - else -> { - e { "Unrecognized OpenPgpApi result: $resultCode" } - null - } - } - } - } - return null - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt deleted file mode 100644 index 25919ad5..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo.ui - -import android.annotation.TargetApi -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.IntentSender -import android.os.Build -import android.os.Bundle -import android.view.View -import android.view.autofill.AutofillManager -import android.widget.TextView -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.viewModels -import androidx.appcompat.app.AppCompatActivity -import androidx.core.text.bold -import androidx.core.text.buildSpannedString -import androidx.core.text.underline -import androidx.core.widget.addTextChangedListener -import androidx.lifecycle.ViewModelProvider -import androidx.recyclerview.widget.LinearLayoutManager -import com.github.ajalt.timberkt.e -import com.github.androidpasswordstore.autofillparser.FormOrigin -import com.zeapo.pwdstore.FilterMode -import com.zeapo.pwdstore.ListMode -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.SearchMode -import com.zeapo.pwdstore.SearchableRepositoryAdapter -import com.zeapo.pwdstore.SearchableRepositoryViewModel -import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher -import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences -import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure -import com.zeapo.pwdstore.databinding.ActivityOreoAutofillFilterBinding -import com.zeapo.pwdstore.utils.PasswordItem -import com.zeapo.pwdstore.utils.viewBinding - -@TargetApi(Build.VERSION_CODES.O) -class AutofillFilterView : AppCompatActivity() { - - companion object { - - private const val HEIGHT_PERCENTAGE = 0.9 - private const val WIDTH_PERCENTAGE = 0.75 - - private const val EXTRA_FORM_ORIGIN_WEB = - "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB" - private const val EXTRA_FORM_ORIGIN_APP = - "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP" - private var matchAndDecryptFileRequestCode = 1 - - fun makeMatchAndDecryptFileIntentSender( - context: Context, - formOrigin: FormOrigin - ): IntentSender { - val intent = Intent(context, AutofillFilterView::class.java).apply { - when (formOrigin) { - is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier) - is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier) - } - } - return PendingIntent.getActivity( - context, - matchAndDecryptFileRequestCode++, - intent, - PendingIntent.FLAG_CANCEL_CURRENT - ).intentSender - } - } - - private lateinit var formOrigin: FormOrigin - private lateinit var directoryStructure: DirectoryStructure - private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate) - - private val model: SearchableRepositoryViewModel by viewModels { - ViewModelProvider.AndroidViewModelFactory(application) - } - - private val decryptAction = registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - setResult(RESULT_OK, result.data) - } - finish() - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - setFinishOnTouchOutside(true) - - val params = window.attributes - params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt() - params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt() - window.attributes = params - - if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) { - e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" } - finish() - return - } - formOrigin = when { - intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> { - FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!) - } - intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> { - FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!) - } - else -> { - e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" } - finish() - return - } - } - directoryStructure = AutofillPreferences.directoryStructure(this) - - supportActionBar?.hide() - bindUI() - updateSearch() - setResult(RESULT_CANCELED) - } - - private fun bindUI() { - with(binding) { - rvPassword.apply { - adapter = SearchableRepositoryAdapter( - R.layout.oreo_autofill_filter_row, - ::PasswordViewHolder - ) { item -> - val file = item.file.relativeTo(item.rootDir) - val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) - val identifier = directoryStructure.getIdentifierFor(file) - val accountPart = directoryStructure.getAccountPartFor(file) - check(identifier != null || accountPart != null) { "At least one of identifier and accountPart should always be non-null" } - title.text = if (identifier != null) { - buildSpannedString { - if (pathToIdentifier != null) - append("$pathToIdentifier/") - bold { underline { append(identifier) } } - } - } else { - accountPart - } - subtitle.apply { - if (identifier != null && accountPart != null) { - text = accountPart - visibility = View.VISIBLE - } else { - visibility = View.GONE - } - } - }.onItemClicked { _, item -> - decryptAndFill(item) - } - layoutManager = LinearLayoutManager(context) - } - search.apply { - val initialSearch = - formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) - setText(initialSearch, TextView.BufferType.EDITABLE) - addTextChangedListener { updateSearch() } - } - origin.text = buildSpannedString { - append(getString(R.string.oreo_autofill_select_and_fill_into)) - append("\n") - bold { - append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) - } - } - strictDomainSearch.apply { - visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE - isChecked = formOrigin is FormOrigin.Web - setOnCheckedChangeListener { _, _ -> updateSearch() } - } - shouldMatch.text = getString( - R.string.oreo_autofill_match_with, - formOrigin.getPrettyIdentifier(applicationContext) - ) - model.searchResult.observe(this@AutofillFilterView) { result -> - val list = result.passwordItems - (rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { - rvPassword.scrollToPosition(0) - } - // Switch RecyclerView out for a "no results" message if the new list is empty and - // the message is not yet shown (and vice versa). - if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) || - (list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id) - ) { - rvPasswordSwitcher.showNext() - } - } - } - } - - private fun updateSearch() { - model.search( - binding.search.text.toString().trim(), - filterMode = if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy, - searchMode = SearchMode.RecursivelyInSubdirectories, - listMode = ListMode.FilesOnly - ) - } - - private fun decryptAndFill(item: PasswordItem) { - if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) - if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor( - applicationContext, - formOrigin, - item.file - ) - // intent?.extras? is checked to be non-null in onCreate - decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent( - item.file, - intent!!.extras!!, - this - )) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt deleted file mode 100644 index 34edf8f5..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillPublisherChangedActivity.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo.ui - -import android.annotation.TargetApi -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.IntentSender -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.service.autofill.FillResponse -import android.text.format.DateUtils -import android.view.View -import android.view.autofill.AutofillManager -import androidx.appcompat.app.AppCompatActivity -import com.github.ajalt.timberkt.e -import com.github.androidpasswordstore.autofillparser.FormOrigin -import com.github.androidpasswordstore.autofillparser.computeCertificatesHash -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher -import com.zeapo.pwdstore.autofill.oreo.AutofillPublisherChangedException -import com.zeapo.pwdstore.databinding.ActivityOreoAutofillPublisherChangedBinding -import com.zeapo.pwdstore.utils.viewBinding - -@TargetApi(Build.VERSION_CODES.O) -class AutofillPublisherChangedActivity : AppCompatActivity() { - - companion object { - - private const val EXTRA_APP_PACKAGE = - "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_APP_PACKAGE" - private const val EXTRA_FILL_RESPONSE_AFTER_RESET = - "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET" - private var publisherChangedRequestCode = 1 - - fun makePublisherChangedIntentSender( - context: Context, - publisherChangedException: AutofillPublisherChangedException, - fillResponseAfterReset: FillResponse?, - ): IntentSender { - val intent = Intent(context, AutofillPublisherChangedActivity::class.java).apply { - putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier) - putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset) - } - return PendingIntent.getActivity( - context, publisherChangedRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT - ).intentSender - } - } - - private lateinit var appPackage: String - private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - setFinishOnTouchOutside(true) - - appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run { - e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" } - finish() - return - } - supportActionBar?.hide() - showPackageInfo() - with(binding) { - okButton.setOnClickListener { finish() } - advancedButton.setOnClickListener { - advancedButton.visibility = View.GONE - warningAppAdvancedInfo.visibility = View.VISIBLE - resetButton.visibility = View.VISIBLE - } - resetButton.setOnClickListener { - AutofillMatcher.clearMatchesFor(this@AutofillPublisherChangedActivity, FormOrigin.App(appPackage)) - val fillResponse = intent.getParcelableExtra(EXTRA_FILL_RESPONSE_AFTER_RESET) - setResult(RESULT_OK, Intent().apply { - putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) - }) - finish() - } - } - } - - private fun showPackageInfo() { - runCatching { - with(binding) { - val packageInfo = - packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA) - val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime) - warningAppInstallDate.text = - getString(R.string.oreo_autofill_warning_publisher_install_time, installTime) - val appInfo = - packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA) - warningAppName.text = "“${packageManager.getApplicationLabel(appInfo)}”" - - val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage) - warningAppAdvancedInfo.text = getString( - R.string.oreo_autofill_warning_publisher_advanced_info_template, - appPackage, - currentHash - ) - } - }.onFailure { e -> - e(e) { "Failed to retrieve package info for $appPackage" } - finish() - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt deleted file mode 100644 index bc012d3d..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillSaveActivity.kt +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo.ui - -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.content.IntentSender -import android.os.Build -import android.os.Bundle -import android.view.autofill.AutofillManager -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity -import androidx.core.os.bundleOf -import com.github.ajalt.timberkt.e -import com.github.androidpasswordstore.autofillparser.AutofillAction -import com.github.androidpasswordstore.autofillparser.Credentials -import com.github.androidpasswordstore.autofillparser.FormOrigin -import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher -import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences -import com.zeapo.pwdstore.autofill.oreo.AutofillResponseBuilder -import com.zeapo.pwdstore.crypto.PasswordCreationActivity -import com.zeapo.pwdstore.utils.PasswordRepository -import java.io.File - -@RequiresApi(Build.VERSION_CODES.O) -class AutofillSaveActivity : AppCompatActivity() { - - companion object { - - private const val EXTRA_FOLDER_NAME = - "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_FOLDER_NAME" - private const val EXTRA_PASSWORD = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_PASSWORD" - private const val EXTRA_NAME = "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_NAME" - private const val EXTRA_SHOULD_MATCH_APP = - "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP" - private const val EXTRA_SHOULD_MATCH_WEB = - "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB" - private const val EXTRA_GENERATE_PASSWORD = - "com.zeapo.pwdstore.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD" - - private var saveRequestCode = 1 - - fun makeSaveIntentSender( - context: Context, - credentials: Credentials?, - formOrigin: FormOrigin - ): IntentSender { - val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false) - // Prevent directory traversals - val sanitizedIdentifier = identifier.replace('\\', '_') - .replace('/', '_') - .trimStart('.') - .takeUnless { it.isBlank() } ?: formOrigin.identifier - val directoryStructure = AutofillPreferences.directoryStructure(context) - val folderName = directoryStructure.getSaveFolderName( - sanitizedIdentifier = sanitizedIdentifier, - username = credentials?.username - ) - val fileName = directoryStructure.getSaveFileName(username = credentials?.username, identifier = identifier) - val intent = Intent(context, AutofillSaveActivity::class.java).apply { - putExtras( - bundleOf( - EXTRA_FOLDER_NAME to folderName, - EXTRA_NAME to fileName, - EXTRA_PASSWORD to credentials?.password, - EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App }, - EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web }, - EXTRA_GENERATE_PASSWORD to (credentials == null) - ) - ) - } - return PendingIntent.getActivity( - context, - saveRequestCode++, - intent, - PendingIntent.FLAG_CANCEL_CURRENT - ).intentSender - } - } - - private val formOrigin by lazy(LazyThreadSafetyMode.NONE) { - val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP) - val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB) - if (shouldMatchApp != null && shouldMatchWeb == null) { - FormOrigin.App(shouldMatchApp) - } else if (shouldMatchApp == null && shouldMatchWeb != null) { - FormOrigin.Web(shouldMatchWeb) - } else { - null - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val repo = PasswordRepository.getRepositoryDirectory() - val saveIntent = Intent(this, PasswordCreationActivity::class.java).apply { - putExtras( - bundleOf( - "REPO_PATH" to repo.absolutePath, - "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath, - PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME), - PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD), - PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) - ) - ) - } - registerForActivityResult(StartActivityForResult()) { result -> - val data = result.data - if (result.resultCode == RESULT_OK && data != null) { - val createdPath = data.getStringExtra("CREATED_FILE")!! - formOrigin?.let { - AutofillMatcher.addMatchFor(this, it, File(createdPath)) - } - val password = data.getStringExtra("PASSWORD") - val resultIntent = if (password != null) { - // Password was generated and should be filled into a form. - val username = data.getStringExtra("USERNAME") - val clientState = - intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { - e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } - finish() - return@registerForActivityResult - } - val credentials = Credentials(username, password, null) - val fillInDataset = AutofillResponseBuilder.makeFillInDataset( - this, - credentials, - clientState, - AutofillAction.Generate - ) - Intent().apply { - putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) - } - } else { - // Password was extracted from a form, there is nothing to fill. - Intent() - } - setResult(RESULT_OK, resultIntent) - } else { - setResult(RESULT_CANCELED) - } - finish() - }.launch(saveIntent) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt deleted file mode 100644 index 162362f6..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/PasswordViewHolder.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.autofill.oreo.ui - -import android.view.View -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import com.zeapo.pwdstore.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/com/zeapo/pwdstore/crypto/BasePgpActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt deleted file mode 100644 index 412a54f2..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/BasePgpActivity.kt +++ /dev/null @@ -1,310 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.crypto - -import android.app.PendingIntent -import android.content.ClipData -import android.content.Intent -import android.content.IntentSender -import android.content.SharedPreferences -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.text.format.DateUtils -import android.view.WindowManager -import androidx.annotation.CallSuper -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import com.github.ajalt.timberkt.Timber.tag -import com.github.ajalt.timberkt.e -import com.github.ajalt.timberkt.i -import com.github.michaelbull.result.getOr -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.zeapo.pwdstore.ClipboardService -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.clipboard -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -import com.zeapo.pwdstore.utils.snackbar -import java.io.File -import me.msfjarvis.openpgpktx.util.OpenPgpApi -import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection -import org.openintents.openpgp.IOpenPgpService2 -import org.openintents.openpgp.OpenPgpError - -@Suppress("Registered") -open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { - - /** - * Full path to the repository - */ - val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! } - - /** - * Full path to the password file being worked on - */ - val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! } - - /** - * Name of the password file - * - * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org - */ - val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension } - - /** - * Get the timestamp for when this file was last modified. - */ - val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) { - getLastChangedString( - intent.getLongExtra( - "LAST_CHANGED_TIMESTAMP", - -1L - ) - ) - } - - /** - * [SharedPreferences] instance used by subclasses to persist settings - */ - val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs } - - /** - * Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain. - */ - private var serviceConnection: OpenPgpServiceConnection? = null - var api: OpenPgpApi? = null - - /** - * A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with - * in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package. - */ - private var previousListener: OpenPgpServiceConnection.OnBound? = null - - /** - * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots - * or recent apps screen. - */ - @CallSuper - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) - tag(TAG) - } - - /** - * [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This - * is annotated with [CallSuper] because it's critical to unbind the service to ensure we're not - * leaking things. - */ - @CallSuper - override fun onDestroy() { - super.onDestroy() - serviceConnection?.unbindFromService() - previousListener = null - } - - /** - * [onResume] controls the flow for resumption of a PGP operation that was previously interrupted - * by the [OPENPGP_PROVIDER] package being missing. - */ - override fun onResume() { - super.onResume() - previousListener?.let { bindToOpenKeychain(it) } - } - - /** - * Sets up [api] once the service is bound. Downstream consumers must call super this to - * initialize [api] - */ - @CallSuper - override fun onBound(service: IOpenPgpService2) { - api = OpenPgpApi(this, service) - } - - /** - * Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle - * their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call super. - */ - override fun onError(e: Exception) { - e(e) { "Callers must handle their own exceptions" } - throw e - } - - /** - * Method for subclasses to initiate binding with [OpenPgpServiceConnection]. - */ - fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) { - val installed = runCatching { - packageManager.getPackageInfo(OPENPGP_PROVIDER, 0) - true - }.getOr(false) - if (!installed) { - previousListener = onBoundListener - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.openkeychain_not_installed_title)) - .setMessage(getString(R.string.openkeychain_not_installed_message)) - .setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ -> - runCatching { - val intent = Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER)) - setPackage("com.android.vending") - } - startActivity(intent) - } - } - .setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ -> - runCatching { - val intent = Intent(Intent.ACTION_VIEW).apply { - data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER)) - } - startActivity(intent) - } - } - .setOnCancelListener { finish() } - .show() - return - } else { - previousListener = null - serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { - it.bindToService() - } - } - } - - /** - * Handle the case where OpenKeychain returns that it needs to interact with the user - * - * @param result The intent returned by OpenKeychain - */ - fun getUserInteractionRequestIntent(result: Intent): IntentSender { - i { "RESULT_CODE_USER_INTERACTION_REQUIRED" } - return result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!.intentSender - } - - /** - * Gets a relative string describing when this shape was last changed - * (e.g. "one hour ago") - */ - private fun getLastChangedString(timeStamp: Long): CharSequence { - if (timeStamp < 0) { - throw RuntimeException() - } - - return DateUtils.getRelativeTimeSpanString(this, timeStamp, true) - } - - /** - * Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses - * can use this when they want to default to sane error handling. - */ - fun handleError(result: Intent) { - val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) - if (error != null) { - when (error.errorId) { - OpenPgpError.NO_OR_WRONG_PASSPHRASE -> { - snackbar(message = getString(R.string.openpgp_error_wrong_passphrase)) - } - OpenPgpError.NO_USER_IDS -> { - snackbar(message = getString(R.string.openpgp_error_no_user_ids)) - } - else -> { - snackbar(message = getString(R.string.openpgp_error_unknown, error.message)) - e { "onError getErrorId: ${error.errorId}" } - e { "onError getMessage: ${error.message}" } - } - } - } - } - - /** - * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing - * [showSnackbar] as false. - */ - fun copyTextToClipboard( - text: String?, - showSnackbar: Boolean = true, - @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text - ) { - val clipboard = clipboard ?: return - val clip = ClipData.newPlainText("pgp_handler_result_pm", text) - clipboard.setPrimaryClip(clip) - if (showSnackbar) { - snackbar(message = resources.getString(snackbarTextRes)) - } - } - - /** - * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to - * hide the default [Snackbar] and starts off an instance of [ClipboardService] to provide a - * way of clearing the clipboard. - */ - fun copyPasswordToClipboard(password: String?) { - copyTextToClipboard(password, showSnackbar = false) - - val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45 - - if (clearAfter != 0) { - val service = Intent(this, ClipboardService::class.java).apply { - action = ClipboardService.ACTION_START - putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - startForegroundService(service) - } else { - startService(service) - } - snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter)) - } else { - snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text)) - } - } - - companion object { - - private const val TAG = "APS/BasePgpActivity" - const val KEY_PWGEN_TYPE_CLASSIC = "classic" - const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" - - /** - * Gets the relative path to the repository - */ - fun getRelativePath(fullPath: String, repositoryPath: String): String = - fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") - - /** - * Gets the Parent path, relative to the repository - */ - fun getParentPath(fullPath: String, repositoryPath: String): String { - val relativePath = getRelativePath(fullPath, repositoryPath) - val index = relativePath.lastIndexOf("/") - return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/") - } - - /** - * /path/to/store/social/facebook.gpg -> social/facebook - */ - @JvmStatic - fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { - var relativePath = getRelativePath(fullPath, repositoryPath) - return if (relativePath.isNotEmpty() && relativePath != "/") { - // remove preceding '/' - relativePath = relativePath.substring(1) - if (relativePath.endsWith('/')) { - relativePath + basename - } else { - "$relativePath/$basename" - } - } else { - basename - } - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt deleted file mode 100644 index cfcecc22..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/DecryptActivity.kt +++ /dev/null @@ -1,260 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.crypto - -import android.content.Intent -import android.graphics.Typeface -import android.os.Bundle -import android.text.method.PasswordTransformationMethod -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult -import androidx.lifecycle.lifecycleScope -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.databinding.DecryptLayoutBinding -import com.zeapo.pwdstore.model.PasswordEntry -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.viewBinding -import java.io.ByteArrayOutputStream -import java.io.File -import kotlin.time.ExperimentalTime -import kotlin.time.seconds -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.msfjarvis.openpgpktx.util.OpenPgpApi -import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection -import org.openintents.openpgp.IOpenPgpService2 - -class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { - - private val binding by viewBinding(DecryptLayoutBinding::inflate) - - private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) } - private var passwordEntry: PasswordEntry? = null - - private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> - if (result.data == null) { - setResult(RESULT_CANCELED, null) - finish() - return@registerForActivityResult - } - - when (result.resultCode) { - RESULT_OK -> decryptAndVerify(result.data) - RESULT_CANCELED -> { - setResult(RESULT_CANCELED, result.data) - finish() - } - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - bindToOpenKeychain(this) - title = name - with(binding) { - setContentView(root) - passwordCategory.text = relativeParentPath - passwordFile.text = name - passwordFile.setOnLongClickListener { - copyTextToClipboard(name) - true - } - passwordLastChanged.run { - runCatching { - text = resources.getString(R.string.last_changed, lastChangedString) - }.onFailure { - visibility = View.GONE - } - } - } - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.pgp_handler, menu) - passwordEntry?.let { entry -> - if (menu != null) { - menu.findItem(R.id.edit_password).isVisible = true - if (entry.password.isNotEmpty()) { - menu.findItem(R.id.share_password_as_plaintext).isVisible = true - menu.findItem(R.id.copy_password).isVisible = true - } - } - } - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> onBackPressed() - R.id.edit_password -> editPassword() - R.id.share_password_as_plaintext -> shareAsPlaintext() - R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password) - } - return super.onOptionsItemSelected(item) - } - - override fun onBound(service: IOpenPgpService2) { - super.onBound(service) - decryptAndVerify() - } - - override fun onError(e: Exception) { - e(e) - } - - /** - * Automatically finishes the activity 60 seconds after decryption succeeded to prevent - * information leaks from stale activities. - */ - @OptIn(ExperimentalTime::class) - private fun startAutoDismissTimer() { - lifecycleScope.launch { - delay(60.seconds) - finish() - } - } - - /** - * Edit the current password and hide all the fields populated by encrypted data so that when - * the result triggers they can be repopulated with new data. - */ - private fun editPassword() { - val intent = Intent(this, PasswordCreationActivity::class.java) - intent.putExtra("FILE_PATH", relativeParentPath) - intent.putExtra("REPO_PATH", repoPath) - intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name) - intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password) - intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent) - intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true) - startActivity(intent) - finish() - } - - private fun shareAsPlaintext() { - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) - type = "text/plain" - } - // Always show a picker to give the user a chance to cancel - startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to))) - } - - @OptIn(ExperimentalTime::class) - private fun decryptAndVerify(receivedIntent: Intent? = null) { - if (api == null) { - bindToOpenKeychain(this) - return - } - val data = receivedIntent ?: Intent() - data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY - - val inputStream = File(fullPath).inputStream() - val outputStream = ByteArrayOutputStream() - - lifecycleScope.launch(Dispatchers.IO) { - api?.executeApiAsync(data, inputStream, outputStream) { result -> - when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - OpenPgpApi.RESULT_CODE_SUCCESS -> { - startAutoDismissTimer() - runCatching { - val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) - val showExtraContent = settings.getBoolean(PreferenceKeys.SHOW_EXTRA_CONTENT, true) - val monoTypeface = Typeface.createFromAsset(assets, "fonts/sourcecodepro.ttf") - val entry = PasswordEntry(outputStream) - - passwordEntry = entry - invalidateOptionsMenu() - - with(binding) { - if (entry.password.isEmpty()) { - passwordTextContainer.visibility = View.GONE - } else { - passwordTextContainer.visibility = View.VISIBLE - passwordText.typeface = monoTypeface - passwordText.setText(entry.password) - if (!showPassword) { - passwordText.transformationMethod = PasswordTransformationMethod.getInstance() - } - passwordTextContainer.setOnClickListener { copyPasswordToClipboard(entry.password) } - passwordText.setOnClickListener { copyPasswordToClipboard(entry.password) } - } - - if (entry.hasExtraContent()) { - if (entry.extraContentWithoutAuthData.isNotEmpty()) { - extraContentContainer.visibility = View.VISIBLE - extraContent.typeface = monoTypeface - extraContent.setText(entry.extraContentWithoutAuthData) - if (!showExtraContent) { - extraContent.transformationMethod = PasswordTransformationMethod.getInstance() - } - extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } - extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } - } - - if (entry.hasUsername()) { - usernameText.typeface = monoTypeface - usernameText.setText(entry.username) - usernameTextContainer.setEndIconOnClickListener { copyTextToClipboard(entry.username) } - usernameTextContainer.visibility = View.VISIBLE - } else { - usernameTextContainer.visibility = View.GONE - } - - if (entry.hasTotp()) { - otpTextContainer.visibility = View.VISIBLE - otpTextContainer.setEndIconOnClickListener { - copyTextToClipboard( - otpText.text.toString(), - snackbarTextRes = R.string.clipboard_otp_copied_text - ) - } - launch(Dispatchers.IO) { - // Calculate the actual remaining time for the first pass - // then return to the standard 30 second affair. - val remainingTime = entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod) - withContext(Dispatchers.Main) { - otpText.setText(entry.calculateTotpCode() - ?: "Error") - } - delay(remainingTime.seconds) - repeat(Int.MAX_VALUE) { - val code = entry.calculateTotpCode() ?: "Error" - withContext(Dispatchers.Main) { - otpText.setText(code) - } - delay(30.seconds) - } - } - } - } - } - - if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) { - copyPasswordToClipboard(entry.password) - } - }.onFailure { e -> - e(e) - } - } - OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { - val sender = getUserInteractionRequestIntent(result) - userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) - } - OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) - } - } - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt deleted file mode 100644 index b6a3e955..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/GetKeyIdsActivity.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.crypto - -import android.content.Intent -import android.os.Bundle -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult -import androidx.lifecycle.lifecycleScope -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import me.msfjarvis.openpgpktx.util.OpenPgpApi -import me.msfjarvis.openpgpktx.util.OpenPgpUtils -import org.openintents.openpgp.IOpenPgpService2 - -class GetKeyIdsActivity : BasePgpActivity() { - - private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> - if (result.data == null || result.resultCode == RESULT_CANCELED) { - setResult(RESULT_CANCELED, result.data) - finish() - return@registerForActivityResult - } - getKeyIds(result.data!!) - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - bindToOpenKeychain(this) - } - - override fun onBound(service: IOpenPgpService2) { - super.onBound(service) - getKeyIds() - } - - override fun onError(e: Exception) { - e(e) - } - - /** - * Get the Key ids from OpenKeychain - */ - private fun getKeyIds(data: Intent = Intent()) { - data.action = OpenPgpApi.ACTION_GET_KEY_IDS - lifecycleScope.launch(Dispatchers.IO) { - api?.executeApiAsync(data, null, null) { result -> - when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - OpenPgpApi.RESULT_CODE_SUCCESS -> { - runCatching { - val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { - OpenPgpUtils.convertKeyIdToHex(it) - } ?: emptyList() - val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray()) - setResult(RESULT_OK, keyResult) - finish() - }.onFailure { e -> - e(e) - } - } - OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { - val sender = getUserInteractionRequestIntent(result) - userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) - } - OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) - } - } - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt b/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt deleted file mode 100644 index 01d85f2b..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/crypto/PasswordCreationActivity.kt +++ /dev/null @@ -1,513 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.crypto - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.text.InputType -import android.view.Menu -import android.view.MenuItem -import android.view.View -import androidx.activity.result.IntentSenderRequest -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult -import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult -import androidx.core.content.edit -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.lifecycleScope -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.zxing.integration.android.IntentIntegrator -import com.google.zxing.integration.android.IntentIntegrator.QR_CODE -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.autofill.oreo.AutofillPreferences -import com.zeapo.pwdstore.autofill.oreo.DirectoryStructure -import com.zeapo.pwdstore.databinding.PasswordCreationActivityBinding -import com.zeapo.pwdstore.model.PasswordEntry -import com.zeapo.pwdstore.ui.dialogs.PasswordGeneratorDialogFragment -import com.zeapo.pwdstore.ui.dialogs.XkPasswordGeneratorDialogFragment -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.base64 -import com.zeapo.pwdstore.utils.commitChange -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.isInsideRepository -import com.zeapo.pwdstore.utils.snackbar -import com.zeapo.pwdstore.utils.viewBinding -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.IOException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.msfjarvis.openpgpktx.util.OpenPgpApi -import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection -import me.msfjarvis.openpgpktx.util.OpenPgpUtils - -class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { - - private val binding by viewBinding(PasswordCreationActivityBinding::inflate) - - private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } - private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) } - private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } - private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) } - private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) } - private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } - private var oldCategory: String? = null - private var copy: Boolean = false - private var encryptionIntent: Intent = Intent() - - private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> - if (result.data == null) { - setResult(RESULT_CANCELED, null) - finish() - return@registerForActivityResult - } - - when (result.resultCode) { - RESULT_OK -> encrypt(result.data) - RESULT_CANCELED -> { - setResult(RESULT_CANCELED, result.data) - finish() - } - } - } - - private val otpImportAction = registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - binding.otpImportButton.isVisible = false - val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) - val contents = "${intentResult.contents}\n" - val currentExtras = binding.extraContent.text.toString() - if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') - binding.extraContent.append("\n$contents") - else - binding.extraContent.append(contents) - snackbar(message = getString(R.string.otp_import_success)) - } else { - snackbar(message = getString(R.string.otp_import_failure)) - } - } - - private val gpgKeySelectAction = registerForActivityResult(StartActivityForResult()) { result -> - if (result.resultCode == RESULT_OK) { - result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> - lifecycleScope.launch { - val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") - withContext(Dispatchers.IO) { - gpgIdentifierFile.writeText(keyIds.joinToString("\n")) - } - commitChange(getString( - R.string.git_commit_gpg_id, - getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) - )).onSuccess { - encrypt(encryptionIntent) - } - } - } - } - } - - private fun File.findTillRoot(fileName: String, rootPath: File): File? { - val gpgFile = File(this, fileName) - if (gpgFile.exists()) return gpgFile - - if (this.absolutePath == rootPath.absolutePath) { - return null - } - - val parent = parentFile - return if (parent != null && parent.exists()) { - parent.findTillRoot(fileName, rootPath) - } else { - null - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - bindToOpenKeychain(this) - title = if (editing) - getString(R.string.edit_password) - else - getString(R.string.new_password_title) - with(binding) { - setContentView(root) - generatePassword.setOnClickListener { generatePassword() } - otpImportButton.setOnClickListener { - otpImportAction.launch(IntentIntegrator(this@PasswordCreationActivity) - .setOrientationLocked(false) - .setBeepEnabled(false) - .setDesiredBarcodeFormats(QR_CODE) - .createScanIntent() - ) - } - - directoryInputLayout.apply { - if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { - isEnabled = true - } else { - setBackgroundColor(getColor(android.R.color.transparent)) - } - val path = getRelativePath(fullPath, repoPath) - // Keep empty path field visible if it is editable. - if (path.isEmpty() && !isEnabled) - visibility = View.GONE - else { - directory.setText(path) - oldCategory = path - } - } - if (suggestedName != null) { - filename.setText(suggestedName) - } else { - filename.requestFocus() - } - // Allow the user to quickly switch between storing the username as the filename or - // in the encrypted extras. This only makes sense if the directory structure is - // FileBased. - if (suggestedName == null && - AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == - DirectoryStructure.FileBased - ) { - encryptUsername.apply { - visibility = View.VISIBLE - setOnClickListener { - if (isChecked) { - // User wants to enable username encryption, so we add it to the - // encrypted extras as the first line. - val username = filename.text.toString() - val extras = "username:$username\n${extraContent.text}" - - filename.text?.clear() - extraContent.setText(extras) - } else { - // User wants to disable username encryption, so we extract the - // username from the encrypted extras and use it as the filename. - val entry = PasswordEntry("PASSWORD\n${extraContent.text}") - val username = entry.username - - // username should not be null here by the logic in - // updateViewState, but it could still happen due to - // input lag. - if (username != null) { - filename.setText(username) - extraContent.setText(entry.extraContentWithoutAuthData) - } - } - } - } - listOf(filename, extraContent).forEach { - it.doOnTextChanged { _, _, _, _ -> updateViewState() } - } - } - suggestedPass?.let { - password.setText(it) - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - suggestedExtra?.let { extraContent.setText(it) } - if (shouldGeneratePassword) { - generatePassword() - password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD - } - } - updateViewState() - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.pgp_handler_new_password, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - setResult(RESULT_CANCELED) - finish() - } - R.id.save_password -> { - copy = false - encrypt() - } - R.id.save_and_copy_password -> { - copy = true - encrypt() - } - else -> return super.onOptionsItemSelected(item) - } - return true - } - - private fun generatePassword() { - when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) { - KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment() - .show(supportFragmentManager, "generator") - KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment() - .show(supportFragmentManager, "xkpwgenerator") - } - } - - private fun updateViewState() = with(binding) { - // Use PasswordEntry to parse extras for username - val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}") - encryptUsername.apply { - if (visibility != View.VISIBLE) - return@apply - val hasUsernameInFileName = filename.text.toString().isNotBlank() - val hasUsernameInExtras = entry.hasUsername() - isEnabled = hasUsernameInFileName xor hasUsernameInExtras - isChecked = hasUsernameInExtras - } - otpImportButton.isVisible = !entry.hasTotp() - } - - private sealed class GpgIdentifier { - data class KeyId(val id: Long) : GpgIdentifier() - data class UserId(val email: String) : GpgIdentifier() - } - - @OptIn(ExperimentalUnsignedTypes::class) - private fun parseGpgIdentifier(identifier: String): GpgIdentifier? { - if (identifier.isEmpty()) return null - // Match long key IDs: - // FF22334455667788 or 0xFF22334455667788 - val maybeLongKeyId = identifier.removePrefix("0x").takeIf { - it.matches("[a-fA-F0-9]{16}".toRegex()) - } - if (maybeLongKeyId != null) { - val keyId = maybeLongKeyId.toULong(16) - return GpgIdentifier.KeyId(keyId.toLong()) - } - - // Match fingerprints: - // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899 - val maybeFingerprint = identifier.removePrefix("0x").takeIf { - it.matches("[a-fA-F0-9]{40}".toRegex()) - } - if (maybeFingerprint != null) { - // Truncating to the long key ID is not a security issue since OpenKeychain only accepts - // non-ambiguous key IDs. - val keyId = maybeFingerprint.takeLast(16).toULong(16) - return GpgIdentifier.KeyId(keyId.toLong()) - } - - return OpenPgpUtils.splitUserId(identifier).email?.let { GpgIdentifier.UserId(it) } - } - - /** - * Encrypts the password and the extra content - */ - private fun encrypt(receivedIntent: Intent? = null) { - with(binding) { - val editName = filename.text.toString().trim() - val editPass = password.text.toString() - val editExtra = extraContent.text.toString() - - if (editName.isEmpty()) { - snackbar(message = resources.getString(R.string.file_toast_text)) - return@with - } else if (editName.contains('/')) { - snackbar(message = resources.getString(R.string.invalid_filename_text)) - return@with - } - - if (editPass.isEmpty() && editExtra.isEmpty()) { - snackbar(message = resources.getString(R.string.empty_toast_text)) - return@with - } - - if (copy) { - copyPasswordToClipboard(editPass) - } - - encryptionIntent = receivedIntent ?: Intent() - encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT - - // pass enters the key ID into `.gpg-id`. - val repoRoot = PasswordRepository.getRepositoryDirectory() - val gpgIdentifierFile = File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot) - if (gpgIdentifierFile == null) { - snackbar(message = resources.getString(R.string.failed_to_find_key_id)) - return@with - } - val gpgIdentifiers = gpgIdentifierFile.readLines() - .filter { it.isNotBlank() } - .map { line -> - parseGpgIdentifier(line) ?: run { - // The line being empty means this is most likely an empty `.gpg-id` file - // we created. Skip the validation so we can make the user add a real ID. - if (line.isEmpty()) return@run - if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { - snackbar(message = resources.getString(R.string.short_key_ids_unsupported)) - } else { - snackbar(message = resources.getString(R.string.invalid_gpg_id)) - } - return@with - } - } - if (gpgIdentifiers.isEmpty()) { - gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java)) - return@with - } - val keyIds = gpgIdentifiers.filterIsInstance().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, true) - - val content = "$editPass\n$editExtra" - val inputStream = ByteArrayInputStream(content.toByteArray()) - val outputStream = ByteArrayOutputStream() - - val path = when { - // If we allowed the user to edit the relative path, we have to consider it here instead - // of fullPath. - directoryInputLayout.isEnabled -> { - val editRelativePath = directory.text.toString().trim() - if (editRelativePath.isEmpty()) { - snackbar(message = resources.getString(R.string.path_toast_text)) - return - } - val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}") - if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) { - snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}") - return - } - - "${passwordDirectory.path}/$editName.gpg" - } - else -> "$fullPath/$editName.gpg" - } - - lifecycleScope.launch(Dispatchers.IO) { - api?.executeApiAsync(encryptionIntent, inputStream, outputStream) { result -> - when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { - OpenPgpApi.RESULT_CODE_SUCCESS -> { - runCatching { - val file = File(path) - // If we're not editing, this file should not already exist! - if (!editing && file.exists()) { - snackbar(message = getString(R.string.password_creation_duplicate_error)) - return@executeApiAsync - } - - if (!file.isInsideRepository()) { - snackbar(message = getString(R.string.message_error_destination_outside_repo)) - return@executeApiAsync - } - - file.outputStream().use { - it.write(outputStream.toByteArray()) - } - - //associate the new password name with the last name's timestamp in history - val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) - val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() - val timestamp = preference.getString(oldFilePathHash) - if (timestamp != null) { - preference.edit { - remove(oldFilePathHash) - putString(file.absolutePath.base64(), timestamp) - } - } - - val returnIntent = Intent() - returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) - returnIntent.putExtra(RETURN_EXTRA_NAME, editName) - returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName)) - - if (shouldGeneratePassword) { - val directoryStructure = - AutofillPreferences.directoryStructure(applicationContext) - val entry = PasswordEntry(content) - returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) - val username = entry.username - ?: directoryStructure.getUsernameFor(file) - returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) - } - - if (directoryInputLayout.isVisible && directoryInputLayout.isEnabled && oldFileName != null) { - val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg") - if (oldFile.path != file.path && !oldFile.delete()) { - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(R.string.password_creation_file_fail_title) - .setMessage(getString(R.string.password_creation_file_delete_fail_message, oldFileName)) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> - finish() - } - .show() - return@executeApiAsync - } - } - - val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text - lifecycleScope.launch { - commitChange(resources.getString( - commitMessageRes, - getLongName(fullPath, repoPath, editName) - )).onSuccess { - setResult(RESULT_OK, returnIntent) - finish() - } - } - - }.onFailure { e -> - if (e is IOException) { - e(e) { "Failed to write password file" } - setResult(RESULT_CANCELED) - MaterialAlertDialogBuilder(this@PasswordCreationActivity) - .setTitle(getString(R.string.password_creation_file_fail_title)) - .setMessage(getString(R.string.password_creation_file_write_fail_message)) - .setCancelable(false) - .setPositiveButton(android.R.string.ok) { _, _ -> - finish() - } - .show() - } else { - e(e) - } - } - } - OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { - val sender = getUserInteractionRequestIntent(result) - userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) - } - OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) - } - } - } - } - } - - companion object { - - private const val KEY_PWGEN_TYPE_CLASSIC = "classic" - private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" - const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" - const val RETURN_EXTRA_NAME = "NAME" - const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" - const val RETURN_EXTRA_USERNAME = "USERNAME" - const val RETURN_EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_FILE_NAME = "FILENAME" - const val EXTRA_PASSWORD = "PASSWORD" - const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" - const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" - const val EXTRA_EDITING = "EDITING" - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt deleted file mode 100644 index b47647c1..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/BaseGitActivity.kt +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import com.github.ajalt.timberkt.d -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.andThen -import com.github.michaelbull.result.mapError -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.operation.BreakOutOfDetached -import com.zeapo.pwdstore.git.operation.CloneOperation -import com.zeapo.pwdstore.git.operation.PullOperation -import com.zeapo.pwdstore.git.operation.PushOperation -import com.zeapo.pwdstore.git.operation.ResetToRemoteOperation -import com.zeapo.pwdstore.git.operation.SyncOperation -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getEncryptedGitPrefs -import com.zeapo.pwdstore.utils.sharedPrefs -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.userauth.UserAuthException - -/** - * Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related - * tasks and makes sense to be held here. - */ -abstract class BaseGitActivity : ContinuationContainerActivity() { - - /** - * Enum of possible Git operations than can be run through [launchGitOperation]. - */ - enum class GitOp { - BREAK_OUT_OF_DETACHED, - CLONE, - PULL, - PUSH, - RESET, - SYNC, - } - - /** - * Attempt to launch the requested Git operation. - * @param operation The type of git operation to launch - */ - suspend fun launchGitOperation(operation: GitOp): Result { - if (GitSettings.url == null) { - return Err(IllegalStateException("Git url is not set!")) - } - if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) { - // If the server does not support multiple SSH channels per connection, we cannot run - // a sync operation without reconnecting and thus break sync into its two parts. - return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) } - } - val op = when (operation) { - GitOp.CLONE -> CloneOperation(this, GitSettings.url!!) - GitOp.PULL -> PullOperation(this) - GitOp.PUSH -> PushOperation(this) - GitOp.SYNC -> SyncOperation(this) - GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this) - GitOp.RESET -> ResetToRemoteOperation(this) - } - return op.executeAfterAuthentication(GitSettings.authMode).mapError { throwable -> - val err = rootCauseException(throwable) - if (err.message?.contains("cannot open additional channels") == true) { - GitSettings.useMultiplexing = false - SSHException(DisconnectReason.TOO_MANY_CONNECTIONS, "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used.") - } else { - err - } - } - } - - fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) { - finish() - } - - suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) { - val error = rootCauseException(err) - if (!isExplicitlyUserInitiatedError(error)) { - getEncryptedGitPrefs().edit { - remove(PreferenceKeys.HTTPS_PASSWORD) - } - sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } - d(error) - withContext(Dispatchers.Main) { - MaterialAlertDialogBuilder(this@BaseGitActivity).run { - setTitle(resources.getString(R.string.jgit_error_dialog_title)) - setMessage(ErrorMessages[error]) - setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } - setOnDismissListener { - onPromptDone() - } - show() - } - } - } else { - onPromptDone() - } - } - - /** - * Check if a given [Throwable] is the result of an error caused by the user cancelling the - * operation. - */ - private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean { - var cause: Throwable? = throwable - while (cause != null) { - if (cause is SSHException && - cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) - return true - cause = cause.cause - } - return false - } - - /** - * Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no - * longer found. - */ - private fun rootCauseException(throwable: Throwable): Throwable { - var rootCause = throwable - // JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ exceptions. - // Also, SSHJ's UserAuthException about exhausting available authentication methods hides - // more useful exceptions. - while ((rootCause is org.eclipse.jgit.errors.TransportException || - rootCause is org.eclipse.jgit.api.errors.TransportException || - rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException || - (rootCause is UserAuthException && - rootCause.message == "Exhausted available authentication methods"))) { - rootCause = rootCause.cause ?: break - } - return rootCause - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt b/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt deleted file mode 100644 index 89676c7a..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/ErrorMessages.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git - -import android.os.RemoteException -import androidx.annotation.StringRes -import com.zeapo.pwdstore.Application -import com.zeapo.pwdstore.R -import java.net.UnknownHostException - -/** - * Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute]. - */ -sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) { - - override val message = super.message!! - - companion object { - - private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt) - } - - /** - * Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. - */ - sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { - - object PullRebaseFailed : PullException(R.string.git_pull_fail_error) - } - - /** - * Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. - */ - sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { - - object NonFastForward : PushException(R.string.git_push_nff_error) - object RemoteRejected : PushException(R.string.git_push_other_error) - class Generic(message: String) : PushException(R.string.git_push_generic_error, message) - } -} - -object ErrorMessages { - - operator fun get(throwable: Throwable?): String { - val resources = Application.instance.resources - if (throwable == null) return resources.getString(R.string.git_unknown_error) - return when (val rootCause = rootCause(throwable)) { - is GitException -> rootCause.message - is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message) - else -> throwable.message ?: resources.getString(R.string.git_unknown_error) - } - } - - private fun rootCause(throwable: Throwable): Throwable { - var cause = throwable - while (cause.cause != null) { - if (cause is GitException) break - val nextCause = cause.cause!! - if (nextCause is RemoteException) break - cause = nextCause - } - return cause - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt deleted file mode 100644 index a86e8c54..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitCommandExecutor.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git - -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 com.zeapo.pwdstore.R -import com.zeapo.pwdstore.git.GitException.PullException -import com.zeapo.pwdstore.git.GitException.PushException -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.operation.GitOperation -import com.zeapo.pwdstore.utils.snackbar -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.eclipse.jgit.api.CommitCommand -import org.eclipse.jgit.api.PullCommand -import org.eclipse.jgit.api.PushCommand -import org.eclipse.jgit.api.RebaseResult -import org.eclipse.jgit.api.StatusCommand -import org.eclipse.jgit.lib.PersonIdent -import org.eclipse.jgit.transport.RemoteRefUpdate - -class GitCommandExecutor( - private val activity: FragmentActivity, - private val operation: GitOperation, -) { - - suspend fun execute(): Result { - val snackbar = activity.snackbar( - message = activity.resources.getString(R.string.git_operation_running), - length = Snackbar.LENGTH_INDEFINITE, - ) - // Count the number of uncommitted files - var nbChanges = 0 - return runCatching { - for (command in operation.commands) { - when (command) { - is StatusCommand -> { - val res = withContext(Dispatchers.IO) { - command.call() - } - nbChanges = res.uncommittedChanges.size - } - is CommitCommand -> { - // the previous status will eventually be used to avoid a commit - if (nbChanges > 0) { - withContext(Dispatchers.IO) { - val name = GitSettings.authorName.ifEmpty { "root" } - val email = GitSettings.authorEmail.ifEmpty { "localhost" } - val identity = PersonIdent(name, email) - command.setAuthor(identity).setCommitter(identity).call() - } - } - } - is PullCommand -> { - val result = withContext(Dispatchers.IO) { - command.call() - } - val rr = result.rebaseResult - if (rr.status == RebaseResult.Status.STOPPED) { - throw PullException.PullRebaseFailed - } - } - is PushCommand -> { - val results = withContext(Dispatchers.IO) { - command.call() - } - for (result in results) { - // Code imported (modified) from Gerrit PushOp, license Apache v2 - for (rru in result.remoteUpdates) { - when (rru.status) { - RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward - RemoteRefUpdate.Status.REJECTED_NODELETE, - RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED, - RemoteRefUpdate.Status.NON_EXISTING, - RemoteRefUpdate.Status.NOT_ATTEMPTED, - -> throw PushException.Generic(rru.status.name) - RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> { - throw if ("non-fast-forward" == rru.message) { - PushException.RemoteRejected - } else { - PushException.Generic(rru.message) - } - } - RemoteRefUpdate.Status.UP_TO_DATE -> { - withContext(Dispatchers.Main) { - Toast.makeText( - activity.applicationContext, - activity.applicationContext.getString(R.string.git_push_up_to_date), - Toast.LENGTH_SHORT - ).show() - } - } - else -> { - } - } - } - } - } - else -> { - withContext(Dispatchers.IO) { - command.call() - } - } - } - } - }.also { - snackbar.dismiss() - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt deleted file mode 100644 index 8c6c7d59..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitConfigActivity.kt +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Patterns -import android.view.MenuItem -import androidx.core.os.postDelayed -import androidx.lifecycle.lifecycleScope -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.databinding.ActivityGitConfigBinding -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.log.GitLogActivity -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.viewBinding -import kotlinx.coroutines.launch -import org.eclipse.jgit.lib.Constants -import org.eclipse.jgit.lib.Repository - -class GitConfigActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitConfigBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - if (GitSettings.authorName.isEmpty()) - binding.gitUserName.requestFocus() - else - binding.gitUserName.setText(GitSettings.authorName) - binding.gitUserEmail.setText(GitSettings.authorEmail) - setupTools() - binding.saveButton.setOnClickListener { - val email = binding.gitUserEmail.text.toString().trim() - val name = binding.gitUserName.text.toString().trim() - if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { - MaterialAlertDialogBuilder(this) - .setMessage(getString(R.string.invalid_email_dialog_text)) - .setPositiveButton(getString(R.string.dialog_ok), null) - .show() - } else { - GitSettings.authorEmail = email - GitSettings.authorName = name - Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - /** - * Sets up the UI components of the tools section. - */ - private fun setupTools() { - val repo = PasswordRepository.getRepository(null) - if (repo != null) { - binding.gitHeadStatus.text = headStatusMsg(repo) - // enable the abort button only if we're rebasing - val isRebasing = repo.repositoryState.isRebasing - binding.gitAbortRebase.isEnabled = isRebasing - binding.gitAbortRebase.alpha = if (isRebasing) 1.0f else 0.5f - } - binding.gitLog.setOnClickListener { - runCatching { - startActivity(Intent(this, GitLogActivity::class.java)) - }.onFailure { ex -> - e(ex) { "Failed to start GitLogActivity" } - } - } - binding.gitAbortRebase.setOnClickListener { - lifecycleScope.launch { - launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED).fold( - success = { - MaterialAlertDialogBuilder(this@GitConfigActivity).run { - setTitle(resources.getString(R.string.git_abort_and_push_title)) - setMessage(resources.getString( - R.string.git_break_out_of_detached_success, - GitSettings.branch, - "conflicting-${GitSettings.branch}-...", - )) - setOnDismissListener() { finish() } - setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } - show() - } - }, - failure = { err -> - promptOnErrorHandler(err) { - finish() - } - }, - ) - } - } - binding.gitResetToRemote.setOnClickListener { - lifecycleScope.launch { - launchGitOperation(GitOp.RESET).fold( - success = ::finishOnSuccessHandler, - failure = { err -> - promptOnErrorHandler(err) { - finish() - } - }, - ) - } - } - } - - /** - * Returns a user-friendly message about the current state of HEAD. - * - * The state is recognized to be either pointing to a branch or detached. - */ - private fun headStatusMsg(repo: Repository): String { - return runCatching { - val headRef = repo.getRef(Constants.HEAD) - if (headRef.isSymbolic) { - val branchName = headRef.target.name - val shortBranchName = Repository.shortenRefName(branchName) - getString(R.string.git_head_on_branch, shortBranchName) - } else { - val commitHash = headRef.objectId.abbreviate(8).name() - getString(R.string.git_head_detached, commitHash) - } - }.getOrElse { ex -> - e(ex) { "Error getting HEAD reference" } - getString(R.string.git_head_missing) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt deleted file mode 100644 index af6540a4..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitServerConfigActivity.kt +++ /dev/null @@ -1,265 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git - -import android.content.Context -import android.content.Intent -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.MenuItem -import android.view.View -import androidx.core.os.postDelayed -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.lifecycle.lifecycleScope -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.databinding.ActivityGitCloneBinding -import com.zeapo.pwdstore.git.config.AuthMode -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.config.Protocol -import com.zeapo.pwdstore.ui.dialogs.BasicBottomSheet -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.snackbar -import com.zeapo.pwdstore.utils.viewBinding -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * Activity that encompasses both the initial clone as well as editing the server config for future - * changes. - */ -class GitServerConfigActivity : BaseGitActivity() { - - private val binding by viewBinding(ActivityGitCloneBinding::inflate) - - private lateinit var newAuthMode: AuthMode - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val isClone = intent?.extras?.getBoolean("cloning") ?: false - if (isClone) { - binding.saveButton.text = getString(R.string.clone_button) - } - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - - newAuthMode = GitSettings.authMode - - binding.authModeGroup.apply { - when (newAuthMode) { - AuthMode.SshKey -> check(binding.authModeSshKey.id) - AuthMode.Password -> check(binding.authModePassword.id) - AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id) - AuthMode.None -> check(View.NO_ID) - } - addOnButtonCheckedListener { _, _, _ -> - when (checkedButtonId) { - binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey - binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain - binding.authModePassword.id -> newAuthMode = AuthMode.Password - View.NO_ID -> newAuthMode = AuthMode.None - } - } - } - - binding.serverUrl.setText(GitSettings.url.also { - if (it.isNullOrEmpty()) return@also - setAuthModes(it.startsWith("http://") || it.startsWith("https://")) - }) - binding.serverBranch.setText(GitSettings.branch) - - binding.serverUrl.doOnTextChanged { text, _, _, _ -> - if (text.isNullOrEmpty()) return@doOnTextChanged - setAuthModes(text.startsWith("http://") || text.startsWith("https://")) - } - - binding.saveButton.setOnClickListener { - val newUrl = binding.serverUrl.text.toString().trim() - // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://` - // in the beginning will cause the port to be seen as part of the path. Let users know - // about it and offer a quickfix. - if (newUrl.contains(PORT_REGEX)) { - if (newUrl.startsWith("https://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.https_scheme_with_port_title) - .setMessageRes(R.string.https_scheme_with_port_message) - .setPositiveButtonClickListener { - binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } else if (!newUrl.startsWith("ssh://")) { - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.ssh_scheme_needed_message) - .setPositiveButtonClickListener { - @Suppress("SetTextI18n") - binding.serverUrl.setText("ssh://$newUrl") - } - .build() - .show(supportFragmentManager, "SSH_SCHEME_WARNING") - return@setOnClickListener - } - } - when (val updateResult = GitSettings.updateConnectionSettingsIfValid( - newAuthMode = newAuthMode, - newUrl = binding.serverUrl.text.toString().trim(), - newBranch = binding.serverBranch.text.toString().trim())) { - GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> { - Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show() - } - - is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> { - when (updateResult.newProtocol) { - Protocol.Https -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_https) - .setPositiveButtonClickListener { - } - .build() - .show(supportFragmentManager, "HTTPS_MISSING_USERNAME") - Protocol.Ssh -> - BasicBottomSheet.Builder(this) - .setTitleRes(R.string.ssh_scheme_needed_title) - .setMessageRes(R.string.git_server_config_save_missing_username_ssh) - .setPositiveButtonClickListener { - } - .build() - .show(supportFragmentManager, "SSH_MISSING_USERNAME") - } - } - GitSettings.UpdateConnectionSettingsResult.Valid -> { - if (isClone && PasswordRepository.getRepository(null) == null) - PasswordRepository.initialize() - if (!isClone) { - Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } else { - cloneRepository() - } - } - is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> { - val message = getString( - R.string.git_server_config_save_auth_mode_mismatch, - updateResult.newProtocol, - updateResult.validModes.joinToString(", "), - ) - Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() - } - } - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - return when (item.itemId) { - android.R.id.home -> { - finish() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private fun setAuthModes(isHttps: Boolean) = with(binding) { - if (isHttps) { - authModeSshKey.isVisible = false - authModeOpenKeychain.isVisible = false - authModePassword.isVisible = true - if (authModeGroup.checkedButtonId != authModePassword.id) - authModeGroup.check(View.NO_ID) - } else { - authModeSshKey.isVisible = true - authModeOpenKeychain.isVisible = true - authModePassword.isVisible = true - if (authModeGroup.checkedButtonId == View.NO_ID) - authModeGroup.check(authModeSshKey.id) - } - } - - /** - * Clones the repository, the directory exists, deletes it - */ - private fun cloneRepository() { - val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory()) - val localDirFiles = localDir.listFiles() ?: emptyArray() - // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder - if (localDir.exists() && localDirFiles.isNotEmpty() && - !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_delete_title) - .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString())) - .setCancelable(false) - .setPositiveButton(R.string.dialog_delete) { dialog, _ -> - runCatching { - lifecycleScope.launch { - val snackbar = snackbar(message = getString(R.string.delete_directory_progress_text), length = Snackbar.LENGTH_INDEFINITE) - withContext(Dispatchers.IO) { - localDir.deleteRecursively() - } - snackbar.dismiss() - launchGitOperation(GitOp.CLONE).fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { err -> - promptOnErrorHandler(err) { - finish() - } - } - ) - } - }.onFailure { e -> - e.printStackTrace() - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - dialog.cancel() - } - .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> - dialog.cancel() - } - .show() - } else { - runCatching { - // Silently delete & replace the lone .git folder if it exists - if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") { - localDir.deleteRecursively() - } - }.onFailure { e -> - e(e) - MaterialAlertDialogBuilder(this).setMessage(e.message).show() - } - lifecycleScope.launch { - launchGitOperation(GitOp.CLONE).fold( - success = { - setResult(RESULT_OK) - finish() - }, - failure = { promptOnErrorHandler(it) }, - ) - } - } - } - - companion object { - private val PORT_REGEX = ":[0-9]{1,5}/".toRegex() - - fun createCloneIntent(context: Context): Intent { - return Intent(context, GitServerConfigActivity::class.java).apply { - putExtra("cloning", true) - } - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt b/app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt deleted file mode 100644 index 27ceb5cb..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/config/GitSettings.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.config - -import androidx.core.content.edit -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.Application -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getEncryptedGitPrefs -import com.zeapo.pwdstore.utils.getEncryptedProxyPrefs -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.File -import org.eclipse.jgit.transport.URIish - -enum class Protocol(val pref: String) { - Ssh("ssh://"), - Https("https://"), - ; - - companion object { - - private val map = values().associateBy(Protocol::pref) - fun fromString(type: String?): Protocol { - return map[type ?: return Ssh] - ?: throw IllegalArgumentException("$type is not a valid Protocol") - } - } -} - -enum class AuthMode(val pref: String) { - SshKey("ssh-key"), - Password("username/password"), - OpenKeychain("OpenKeychain"), - None("None"), - ; - - companion object { - - private val map = values().associateBy(AuthMode::pref) - fun fromString(type: String?): AuthMode { - return map[type ?: return SshKey] - ?: throw IllegalArgumentException("$type is not a valid AuthMode") - } - } -} - -object GitSettings { - - private const val DEFAULT_BRANCH = "master" - - private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs } - private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedGitPrefs() } - private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() } - - var authMode - get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH)) - private set(value) { - settings.edit { - putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) - } - } - var url - get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL) - private set(value) { - require(value != null) - if (value == url) - return - settings.edit { - putString(PreferenceKeys.GIT_REMOTE_URL, value) - } - if (PasswordRepository.isInitialized) - PasswordRepository.addRemote("origin", value, true) - // When the server changes, remote password, multiplexing support and host key file - // should be deleted/reset. - useMultiplexing = true - encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } - File("${Application.instance.filesDir}/.host_key").delete() - } - var authorName - get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: "" - set(value) { - settings.edit { - putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) - } - } - var authorEmail - get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: "" - set(value) { - settings.edit { - putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) - } - } - var branch - get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH - private set(value) { - settings.edit { - putString(PreferenceKeys.GIT_BRANCH_NAME, value) - } - } - var useMultiplexing - get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true) - set(value) { - settings.edit { - putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) - } - } - - var proxyHost - get() = proxySettings.getString(PreferenceKeys.PROXY_HOST) - set(value) { - proxySettings.edit { - putString(PreferenceKeys.PROXY_HOST, value) - } - } - - var proxyPort - get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1) - set(value) { - proxySettings.edit { - putInt(PreferenceKeys.PROXY_PORT, value) - } - } - - var proxyUsername - get() = settings.getString(PreferenceKeys.PROXY_USERNAME) - set(value) { - proxySettings.edit { - putString(PreferenceKeys.PROXY_USERNAME, value) - } - } - - var proxyPassword - get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD) - set(value) { - proxySettings.edit { - putString(PreferenceKeys.PROXY_PASSWORD, value) - } - } - - sealed class UpdateConnectionSettingsResult { - class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult() - class AuthModeMismatch(val newProtocol: Protocol, val validModes: List) : UpdateConnectionSettingsResult() - object Valid : UpdateConnectionSettingsResult() - object FailedToParseUrl : UpdateConnectionSettingsResult() - } - - fun updateConnectionSettingsIfValid(newAuthMode: AuthMode, newUrl: String, newBranch: String): UpdateConnectionSettingsResult { - val parsedUrl = runCatching { - URIish(newUrl) - }.getOrElse { - return UpdateConnectionSettingsResult.FailedToParseUrl - } - val newProtocol = when (parsedUrl.scheme) { - in listOf("http", "https") -> Protocol.Https - in listOf("ssh", null) -> Protocol.Ssh - else -> return UpdateConnectionSettingsResult.FailedToParseUrl - } - if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank()) - return UpdateConnectionSettingsResult.MissingUsername(newProtocol) - - val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password) - val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey) - when { - newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> { - return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth) - } - newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> { - return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth) - } - } - - url = newUrl - authMode = newAuthMode - branch = newBranch - return UpdateConnectionSettingsResult.Valid - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/log/GitCommit.kt b/app/src/main/java/com/zeapo/pwdstore/git/log/GitCommit.kt deleted file mode 100644 index d2425592..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/log/GitCommit.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git.log - -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/com/zeapo/pwdstore/git/log/GitLogActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogActivity.kt deleted file mode 100644 index 1e616bae..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogActivity.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git.log - -import android.os.Bundle -import android.view.MenuItem -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import com.zeapo.pwdstore.databinding.ActivityGitLogBinding -import com.zeapo.pwdstore.git.BaseGitActivity -import com.zeapo.pwdstore.utils.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/com/zeapo/pwdstore/git/log/GitLogAdapter.kt b/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogAdapter.kt deleted file mode 100644 index a15e7f7e..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogAdapter.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git.log - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.github.ajalt.timberkt.e -import com.zeapo.pwdstore.databinding.GitLogRowLayoutBinding -import java.text.DateFormat -import java.util.Date - -private fun shortHash(hash: String): String { - return hash.substring(0 until 8) -} - -private fun stringFrom(date: Date): String { - return DateFormat.getDateTimeInstance().format(date) -} - -/** - * @see GitLogActivity - */ -class GitLogAdapter : RecyclerView.Adapter() { - - private val model = GitLogModel() - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val inflater = LayoutInflater.from(parent.context) - val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false) - return ViewHolder(binding) - } - - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val commit = model.get(position) - if (commit == null) { - e { "There is no git commit for view holder at position $position." } - return - } - viewHolder.bind(commit) - } - - override fun getItemCount() = model.size - - class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) { - - fun bind(commit: GitCommit) = with(binding) { - gitLogRowMessage.text = commit.shortMessage - gitLogRowHash.text = shortHash(commit.hash) - gitLogRowTime.text = stringFrom(commit.time) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogModel.kt b/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogModel.kt deleted file mode 100644 index 15b9f64d..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/log/GitLogModel.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.git.log - -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.hash -import com.zeapo.pwdstore.utils.time -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.revwalk.RevCommit - -private fun commits(): Iterable { - val repo = PasswordRepository.getRepository(null) - if (repo == null) { - e { "Could not access git repository" } - return listOf() - } - return runCatching { - Git(repo).log().call() - }.getOrElse { e -> - e(e) { "Failed to obtain git commits" } - listOf() - } -} - -/** - * Provides [GitCommit]s from a git-log of the password git repository. - * - * All commits are acquired on the first request to this object. - */ -class GitLogModel { - - // All commits are acquired here at once. Acquiring the commits in batches would not have been - // entirely sensible because the amount of computation required to obtain commit number n from - // the log includes the amount of computation required to obtain commit number n-1 from the log. - // This is because the commit graph is walked from HEAD to the last commit to obtain. - // Additionally, tests with 1000 commits in the log have not produced a significant delay in the - // user experience. - private val cache: MutableList by lazy(LazyThreadSafetyMode.NONE) { - commits().map { - GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) - }.toMutableList() - } - val size = cache.size - - fun get(index: Int): GitCommit? { - if (index >= size) e { "Cannot get git commit with index $index. There are only $size." } - return cache.getOrNull(index) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt deleted file mode 100644 index f7759ada..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/BreakOutOfDetached.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.RebaseCommand - -class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { - - override val commands = arrayOf( - // abort the rebase - git.rebase().setOperation(RebaseCommand.Operation.ABORT), - // git checkout -b conflict-branch - git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"), - // push the changes - git.push().setRemote("origin"), - // switch back to ${gitBranch} - git.checkout().setName(remoteBranch), - ) - - override fun preExecute() = if (!git.repository.repositoryState.isRebasing) { - MaterialAlertDialogBuilder(callingActivity) - .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) - .setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded)) - .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> - callingActivity.finish() - }.show() - false - } else { - true - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt deleted file mode 100644 index 2a94a283..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/CloneOperation.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.zeapo.pwdstore.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/com/zeapo/pwdstore/git/operation/CredentialFinder.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt deleted file mode 100644 index c34af18a..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/CredentialFinder.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.zeapo.pwdstore.git.operation - -import android.annotation.SuppressLint -import android.view.LayoutInflater -import androidx.annotation.StringRes -import androidx.core.content.edit -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.FragmentActivity -import com.google.android.material.checkbox.MaterialCheckBox -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.git.config.AuthMode -import com.zeapo.pwdstore.git.sshj.InteractivePasswordFinder -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getEncryptedGitPrefs -import com.zeapo.pwdstore.utils.requestInputFocusOnView -import kotlin.coroutines.Continuation -import kotlin.coroutines.resume - -class CredentialFinder( - val callingActivity: FragmentActivity, - val authMode: AuthMode -) : InteractivePasswordFinder() { - - override fun askForPassword(cont: Continuation, isRetry: Boolean) { - val gitOperationPrefs = callingActivity.getEncryptedGitPrefs() - val credentialPref: String - @StringRes val messageRes: Int - @StringRes val hintRes: Int - @StringRes val rememberRes: Int - @StringRes val errorRes: Int - when (authMode) { - AuthMode.SshKey -> { - credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE - messageRes = R.string.passphrase_dialog_text - hintRes = R.string.ssh_keygen_passphrase - rememberRes = R.string.git_operation_remember_passphrase - errorRes = R.string.git_operation_wrong_passphrase - } - AuthMode.Password -> { - // Could be either an SSH or an HTTPS password - credentialPref = PreferenceKeys.HTTPS_PASSWORD - messageRes = R.string.password_dialog_text - hintRes = R.string.git_operation_hint_password - rememberRes = R.string.git_operation_remember_password - errorRes = R.string.git_operation_wrong_password - } - else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords") - } - if (isRetry) - gitOperationPrefs.edit { remove(credentialPref) } - val storedCredential = gitOperationPrefs.getString(credentialPref, null) - if (storedCredential == null) { - val layoutInflater = LayoutInflater.from(callingActivity) - - @SuppressLint("InflateParams") - val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) - val credentialLayout = dialogView.findViewById(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 { - requestInputFocusOnView(R.id.git_auth_credential) - show() - } - } else { - cont.resume(storedCredential) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt deleted file mode 100644 index 5649e8de..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/GitOperation.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import android.content.Intent -import android.widget.Toast -import androidx.fragment.app.FragmentActivity -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.UserPreference -import com.zeapo.pwdstore.git.GitCommandExecutor -import com.zeapo.pwdstore.git.config.AuthMode -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity -import com.zeapo.pwdstore.git.sshj.SshAuthMethod -import com.zeapo.pwdstore.git.sshj.SshKey -import com.zeapo.pwdstore.git.sshj.SshjSessionFactory -import com.zeapo.pwdstore.utils.BiometricAuthenticator -import com.zeapo.pwdstore.utils.PasswordRepository -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.userauth.password.PasswordFinder -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.api.GitCommand -import org.eclipse.jgit.api.TransportCommand -import org.eclipse.jgit.errors.UnsupportedCredentialItem -import org.eclipse.jgit.transport.CredentialItem -import org.eclipse.jgit.transport.CredentialsProvider -import org.eclipse.jgit.transport.SshTransport -import org.eclipse.jgit.transport.Transport -import org.eclipse.jgit.transport.URIish - -/** - * Creates a new git operation - * - * @param callingActivity the calling activity - */ -abstract class GitOperation(protected val callingActivity: FragmentActivity) { - - abstract val commands: Array> - private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") - private var sshSessionFactory: SshjSessionFactory? = null - - protected val repository = PasswordRepository.getRepository(null)!! - protected val git = Git(repository) - protected val remoteBranch = GitSettings.branch - private val authActivity get() = callingActivity as ContinuationContainerActivity - - private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() { - - private var cachedPassword: CharArray? = null - - override fun isInteractive() = true - - override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { - for (item in items) { - when (item) { - is CredentialItem.Username -> item.value = uri?.user - is CredentialItem.Password -> { - item.value = cachedPassword?.clone() - ?: passwordFinder.reqPassword(null).also { - cachedPassword = it.clone() - } - } - else -> UnsupportedCredentialItem(uri, item.javaClass.name) - } - } - return true - } - - override fun supports(vararg items: CredentialItem) = items.all { - it is CredentialItem.Username || it is CredentialItem.Password - } - - override fun reset(uri: URIish?) { - cachedPassword?.fill(0.toChar()) - cachedPassword = null - } - } - - private fun getSshKey(make: Boolean) { - runCatching { - // Ask the UserPreference to provide us with the ssh-key - val intent = Intent(callingActivity.applicationContext, UserPreference::class.java) - intent.putExtra("operation", if (make) "make_ssh_key" else "get_ssh_key") - callingActivity.startActivity(intent) - }.onFailure { e -> - e(e) - } - } - - private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) { - sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile) - commands.filterIsInstance>().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) { - if (it !is BiometricAuthenticator.Result.Failure) - cont.resume(it) - } - } - } - when (result) { - is BiometricAuthenticator.Result.Success -> { - registerAuthProviders(SshAuthMethod.SshKey(authActivity)) - } - is BiometricAuthenticator.Result.Cancelled -> { - return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) - } - is BiometricAuthenticator.Result.Failure -> { - throw IllegalStateException("Biometric authentication failures should be ignored") - } - else -> { - // There is a chance we succeed if the user recently confirmed - // their screen lock. Doing so would have a potential to confuse - // users though, who might deduce that the screen lock - // protection is not effective. Hence, we fail with an error. - Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show() - callingActivity.finish() - } - } - } else { - registerAuthProviders(SshAuthMethod.SshKey(authActivity)) - } - } else { - onMissingSshKeyFile() - // This would correctly cancel the operation but won't surface a user-visible - // error, allowing users to make the SSH key selection. - return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) - } - AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity)) - AuthMode.Password -> { - val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password)) - registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) - } - AuthMode.None -> { - } - } - return execute() - } - - /** - * Called before execution of the Git operation. - * Return false to cancel. - */ - open fun preExecute() = true - - private suspend fun postExecute() { - withContext(Dispatchers.IO) { - sshSessionFactory?.close() - } - } - - companion object { - - /** - * Timeout in seconds before [TransportCommand] will abort a stalled IO operation. - */ - private const val CONNECT_TIMEOUT = 10 - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt deleted file mode 100644 index 4dcc3ec4..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/PullOperation.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity -import org.eclipse.jgit.api.GitCommand - -class PullOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { - - /** - * The story of why the pull operation is committing files goes like this: Once upon a time when - * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made - * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing. - * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation] - * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation, - * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly - * replicating [SyncOperation] but leaving the pushing part to [PushOperation]. - */ - override val commands: Array> = arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Populate the changed files count - git.status(), - // Commit everything! If needed, obviously. - git.commit().setAll(true).setMessage("[Android Password Store] Sync"), - // Pull and rebase on top of the remote branch - git.pull().setRebase(true).setRemote("origin"), - ) -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt deleted file mode 100644 index 28d7a6fa..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/PushOperation.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.zeapo.pwdstore.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/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt deleted file mode 100644 index ad0fb823..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/ResetToRemoteOperation.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.zeapo.pwdstore.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/com/zeapo/pwdstore/git/operation/SyncOperation.kt b/app/src/main/java/com/zeapo/pwdstore/git/operation/SyncOperation.kt deleted file mode 100644 index 8c1ec1fa..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/operation/SyncOperation.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.operation - -import com.zeapo.pwdstore.git.sshj.ContinuationContainerActivity - -class SyncOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { - - override val commands = arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Populate the changed files count - git.status(), - // Commit everything! If needed, obviously. - git.commit().setAll(true).setMessage("[Android Password Store] Sync"), - // Pull and rebase on top of the remote branch - git.pull().setRebase(true).setRemote("origin"), - // Push it all back - git.push().setPushAll().setRemote("origin"), - ) -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/ContinuationContainerActivity.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/ContinuationContainerActivity.kt deleted file mode 100644 index 32ee64a8..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/ContinuationContainerActivity.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.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/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt deleted file mode 100644 index 97774d92..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainKeyProvider.kt +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.sshj - -import android.app.PendingIntent -import android.content.Intent -import androidx.activity.result.IntentSenderRequest -import androidx.core.content.edit -import com.github.ajalt.timberkt.d -import com.zeapo.pwdstore.utils.OPENPGP_PROVIDER -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.Closeable -import java.security.PublicKey -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.KeyType -import net.schmizz.sshj.userauth.UserAuthException -import net.schmizz.sshj.userauth.keyprovider.KeyProvider -import org.openintents.ssh.authentication.ISshAuthenticationService -import org.openintents.ssh.authentication.SshAuthenticationApi -import org.openintents.ssh.authentication.SshAuthenticationApiError -import org.openintents.ssh.authentication.SshAuthenticationConnection -import org.openintents.ssh.authentication.request.KeySelectionRequest -import org.openintents.ssh.authentication.request.Request -import org.openintents.ssh.authentication.request.SigningRequest -import org.openintents.ssh.authentication.request.SshPublicKeyRequest -import org.openintents.ssh.authentication.response.KeySelectionResponse -import org.openintents.ssh.authentication.response.Response -import org.openintents.ssh.authentication.response.SigningResponse -import org.openintents.ssh.authentication.response.SshPublicKeyResponse - -class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : KeyProvider, Closeable { - - companion object { - - suspend fun prepareAndUse(activity: ContinuationContainerActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) { - withContext(Dispatchers.Main) { - OpenKeychainKeyProvider(activity) - }.prepareAndUse(block) - } - } - - private sealed class ApiResponse { - data class Success(val response: Response) : ApiResponse() - data class GeneralError(val exception: Exception) : ApiResponse() - data class NoSuchKey(val exception: Exception) : ApiResponse() - } - - private val context = activity.applicationContext - private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER) - private val preferences = context.sharedPrefs - private lateinit var sshServiceApi: SshAuthenticationApi - - private var keyId - get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) - set(value) { - preferences.edit { - putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) - } - } - private var publicKey: PublicKey? = null - private var privateKey: OpenKeychainPrivateKey? = null - - private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) { - prepare() - use(block) - } - - private suspend fun prepare() { - sshServiceApi = suspendCoroutine { cont -> - sshServiceConnection.connect(object : SshAuthenticationConnection.OnBound { - override fun onBound(sshAgent: ISshAuthenticationService) { - d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" } - cont.resume(SshAuthenticationApi(context, sshAgent)) - } - - override fun onError() { - throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable") - } - }) - } - - if (keyId == null) { - selectKey() - } - check(keyId != null) - fetchPublicKey() - makePrivateKey() - } - - private suspend fun fetchPublicKey(isRetry: Boolean = false) { - when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) { - is ApiResponse.Success -> { - val response = sshPublicKeyResponse.response as SshPublicKeyResponse - val sshPublicKey = response.sshPublicKey!! - publicKey = parseSshPublicKey(sshPublicKey) - ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key") - } - is ApiResponse.NoSuchKey -> if (isRetry) { - throw sshPublicKeyResponse.exception - } else { - // Allow the user to reselect an authentication key and retry - selectKey() - fetchPublicKey(true) - } - is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception - } - } - - private suspend fun selectKey() { - when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) { - is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId - is ApiResponse.GeneralError -> throw keySelectionResponse.exception - is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception - } - } - - private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse { - d { "executeRequest($request) called" } - val result = withContext(Dispatchers.Main) { - // If the request required user interaction, the data returned from the PendingIntent - // is used as the real request. - sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!! - } - return parseResult(request, result).also { - d { "executeRequest($request): $it" } - } - } - - private suspend fun parseResult(request: Request, result: Intent): ApiResponse { - return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) { - SshAuthenticationApi.RESULT_CODE_SUCCESS -> { - ApiResponse.Success(when (request) { - is KeySelectionRequest -> KeySelectionResponse(result) - is SshPublicKeyRequest -> SshPublicKeyResponse(result) - is SigningRequest -> SigningResponse(result) - else -> throw IllegalArgumentException("Unsupported OpenKeychain request type") - }) - } - SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { - val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!! - val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) { - suspendCoroutine { cont -> - activity.stashedCont = cont - activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build()) - } - } - executeApiRequest(request, resultOfUserInteraction) - } - else -> { - val error = result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR) - val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}") - when (error?.error) { - SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception) - else -> ApiResponse.GeneralError(exception) - } - } - } - } - - private fun makePrivateKey() { - check(keyId != null && publicKey != null) - privateKey = object : OpenKeychainPrivateKey { - override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = - when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) { - is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature - is ApiResponse.GeneralError -> throw signingResponse.exception - is ApiResponse.NoSuchKey -> throw signingResponse.exception - } - - override fun getAlgorithm() = publicKey!!.algorithm - } - } - - override fun close() { - activity.continueAfterUserInteraction.unregister() - sshServiceConnection.disconnect() - } - - override fun getPrivate() = privateKey - - override fun getPublic() = publicKey - - override fun getType() = KeyType.fromKey(publicKey) -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt deleted file mode 100644 index 97b587fd..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.sshj - -import com.hierynomus.sshj.key.KeyAlgorithm -import java.io.ByteArrayOutputStream -import java.security.PrivateKey -import kotlinx.coroutines.runBlocking -import net.schmizz.sshj.common.Buffer -import net.schmizz.sshj.common.Factory -import net.schmizz.sshj.signature.Signature -import org.openintents.ssh.authentication.SshAuthenticationApi - -interface OpenKeychainPrivateKey : PrivateKey { - - suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray - - override fun getFormat() = null - override fun getEncoded() = null -} - -class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named) : 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/com/zeapo/pwdstore/git/sshj/SshKey.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt deleted file mode 100644 index 52297133..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshKey.kt +++ /dev/null @@ -1,336 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.sshj - -import android.content.Context -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.provider.OpenableColumns -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyInfo -import android.security.keystore.KeyProperties -import android.util.Base64 -import androidx.core.content.edit -import androidx.security.crypto.EncryptedFile -import androidx.security.crypto.MasterKey -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.Application -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getEncryptedGitPrefs -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.File -import java.io.IOException -import java.security.KeyFactory -import java.security.KeyPairGenerator -import java.security.KeyStore -import java.security.PrivateKey -import java.security.PublicKey -import javax.crypto.SecretKey -import javax.crypto.SecretKeyFactory -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import net.i2p.crypto.eddsa.EdDSAPrivateKey -import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable -import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.common.Buffer -import net.schmizz.sshj.common.KeyType -import net.schmizz.sshj.userauth.keyprovider.KeyProvider - -private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" -private const val KEYSTORE_ALIAS = "sshkey" -private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs" - -private val androidKeystore: KeyStore by lazy(LazyThreadSafetyMode.NONE) { - KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } -} - -private val KeyStore.sshPrivateKey - get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey - -private val KeyStore.sshPublicKey - get() = getCertificate(KEYSTORE_ALIAS)?.publicKey - -fun parseSshPublicKey(sshPublicKey: String): PublicKey? { - val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) - if (sshKeyParts.size < 2) - return null - return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey() -} - -fun toSshPublicKey(publicKey: PublicKey): String { - val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData - val keyType = KeyType.fromKey(publicKey) - return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}" -} - -object SshKey { - - val sshPublicKey - get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null - val canShowSshPublicKey - get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519) - val exists - get() = type != null - val mustAuthenticate: Boolean - get() { - return runCatching { - if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) - return false - when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) { - is PrivateKey -> { - val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) - return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired - } - is SecretKey -> { - val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) - (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired - } - else -> throw IllegalStateException("SSH key does not exist in Keystore") - } - }.getOrElse { error -> - // It is fine to swallow the exception here since it will reappear when the key is - // used for SSH authentication and can then be shown in the UI. - d(error) - false - } - } - - private val context: Context - get() = Application.instance.applicationContext - - private val privateKeyFile - get() = File(context.filesDir, ".ssh_key") - private val publicKeyFile - get() = File(context.filesDir, ".ssh_key.pub") - - private var type: Type? - get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE)) - set(value) = context.sharedPrefs.edit { - putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) - } - - private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) - else - false - } - - private enum class Type(val value: String) { - Imported("imported"), - KeystoreNative("keystore_native"), - KeystoreWrappedEd25519("keystore_wrapped_ed25519"), - - // Behaves like `Imported`, but allows to view the public key. - LegacyGenerated("legacy_generated"), - ; - - companion object { - - fun fromValue(value: String?): Type? = values().associateBy { it.value }[value] - } - } - - enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) { - Rsa(KeyProperties.KEY_ALGORITHM_RSA, { - setKeySize(3072) - setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) - setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) - }), - Ecdsa(KeyProperties.KEY_ALGORITHM_EC, { - setKeySize(256) - setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) - setDigests(KeyProperties.DIGEST_SHA256) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setIsStrongBoxBacked(isStrongBoxSupported) - } - }), - } - - private fun delete() { - androidKeystore.deleteEntry(KEYSTORE_ALIAS) - // Remove Tink key set used by AndroidX's EncryptedFile. - context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { - clear() - } - if (privateKeyFile.isFile) { - privateKeyFile.delete() - } - if (publicKeyFile.isFile) { - publicKeyFile.delete() - } - context.getEncryptedGitPrefs().edit { - remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) - } - type = null - } - - fun import(uri: Uri) { - // First check whether the content at uri is likely an SSH private key. - val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) - ?.use { cursor -> - // Cursor returns only a single row. - cursor.moveToFirst() - cursor.getInt(0) - } ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) - - // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. - if (fileSize > 100_000 || fileSize == 0) - throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) - - val sshKeyInputStream = context.contentResolver.openInputStream(uri) - ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) - val lines = sshKeyInputStream.bufferedReader().readLines() - - // The file must have more than 2 lines, and the first and last line must have private key - // markers. - if (lines.size < 2 || - !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || - !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) - ) - throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) - - // At this point, we are reasonably confident that we have actually been provided a private - // key and delete the old key. - delete() - // Canonicalize line endings to '\n'. - privateKeyFile.writeText(lines.joinToString("\n")) - - type = Type.Imported - } - - @Deprecated("To be used only in Migrations.kt") - fun useLegacyKey(isGenerated: Boolean) { - type = if (isGenerated) Type.LegacyGenerated else Type.Imported - } - - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { - MasterKey.Builder(context, KEYSTORE_ALIAS).run { - setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - setRequestStrongBoxBacked(true) - setUserAuthenticationRequired(requireAuthentication, 15) - build() - } - } - - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { - EncryptedFile.Builder(context, - privateKeyFile, - getOrCreateWrappingMasterKey(requireAuthentication), - EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).run { - setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME) - build() - } - } - - @Suppress("BlockingMethodInNonBlockingContext") - suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { - delete() - - val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication) - // Generate the ed25519 key pair and encrypt the private key. - val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() - encryptedPrivateKeyFile.openFileOutput().use { os -> - os.write((keyPair.private as EdDSAPrivateKey).seed) - } - - // Write public key in SSH format to .ssh_key.pub. - publicKeyFile.writeText(toSshPublicKey(keyPair.public)) - - type = Type.KeystoreWrappedEd25519 - } - - fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) { - delete() - - // Generate Keystore-backed private key. - val parameterSpec = KeyGenParameterSpec.Builder( - KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN - ).run { - apply(algorithm.applyToSpec) - if (requireAuthentication) { - setUserAuthenticationRequired(true) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL) - } else { - @Suppress("DEPRECATION") - setUserAuthenticationValidityDurationSeconds(30) - } - } - build() - } - val keyPair = KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run { - initialize(parameterSpec) - generateKeyPair() - } - - // Write public key in SSH format to .ssh_key.pub. - publicKeyFile.writeText(toSshPublicKey(keyPair.public)) - - type = Type.KeystoreNative - } - - fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = when (type) { - Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder) - Type.KeystoreNative -> KeystoreNativeKeyProvider - Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider - null -> null - } - - private object KeystoreNativeKeyProvider : KeyProvider { - - override fun getPublic(): PublicKey = runCatching { - androidKeystore.sshPublicKey!! - }.getOrElse { error -> - e(error) - throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error) - } - - override fun getPrivate(): PrivateKey = runCatching { - androidKeystore.sshPrivateKey!! - }.getOrElse { error -> - e(error) - throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error) - } - - override fun getType(): KeyType = KeyType.fromKey(public) - } - - private object KeystoreWrappedEd25519KeyProvider : KeyProvider { - - override fun getPublic(): PublicKey = runCatching { - parseSshPublicKey(sshPublicKey!!)!! - }.getOrElse { error -> - e(error) - throw IOException("Failed to get the public key for wrapped ed25519 key", error) - } - - override fun getPrivate(): PrivateKey = runCatching { - // The current MasterKey API does not allow getting a reference to an existing one - // without specifying the KeySpec for a new one. However, the value for passed here - // for `requireAuthentication` is not used as the key already exists at this point. - val encryptedPrivateKeyFile = runBlocking { - getOrCreateWrappedPrivateKeyFile(false) - } - val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() } - EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)) - }.getOrElse { error -> - e(error) - throw IOException("Failed to unwrap wrapped ed25519 key", error) - } - - override fun getType(): KeyType = KeyType.fromKey(public) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt deleted file mode 100644 index cd280db5..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjConfig.kt +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.sshj - -import com.github.ajalt.timberkt.Timber -import com.github.ajalt.timberkt.d -import com.github.michaelbull.result.runCatching -import com.hierynomus.sshj.key.KeyAlgorithms -import com.hierynomus.sshj.transport.cipher.BlockCiphers -import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory -import com.hierynomus.sshj.transport.mac.Macs -import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile -import java.security.Security -import net.schmizz.keepalive.KeepAliveProvider -import net.schmizz.sshj.ConfigImpl -import net.schmizz.sshj.common.LoggerFactory -import net.schmizz.sshj.common.SecurityUtils -import net.schmizz.sshj.transport.compression.NoneCompression -import net.schmizz.sshj.transport.kex.Curve25519SHA256 -import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh -import net.schmizz.sshj.transport.kex.DHGexSHA256 -import net.schmizz.sshj.transport.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) - } - d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } - // Prevent sshj from forwarding all cryptographic operations to BC. - SecurityUtils.setRegisterBouncyCastle(false) - SecurityUtils.setSecurityProvider(null) -} - -private abstract class AbstractLogger(private val name: String) : Logger { - - abstract fun t(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun d(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun i(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun w(message: String, t: Throwable? = null, vararg args: Any?) - abstract fun e(message: String, t: Throwable? = null, vararg args: Any?) - - override fun getName() = name - - override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled - override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled - override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled - override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled - override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled - - override fun trace(msg: String) = t(msg) - override fun trace(format: String, arg: Any?) = t(format, null, arg) - override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2) - override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments) - override fun trace(msg: String, t: Throwable?) = t(msg, t) - override fun trace(marker: Marker, msg: String) = trace(msg) - override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg) - override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - trace(format, arg1, arg2) - - override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = - trace(format, *arguments) - - override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t) - - override fun debug(msg: String) = d(msg) - override fun debug(format: String, arg: Any?) = d(format, null, arg) - override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2) - override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments) - override fun debug(msg: String, t: Throwable?) = d(msg, t) - override fun debug(marker: Marker, msg: String) = debug(msg) - override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg) - override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - debug(format, arg1, arg2) - - override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = - debug(format, *arguments) - - override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t) - - override fun info(msg: String) = i(msg) - override fun info(format: String, arg: Any?) = i(format, null, arg) - override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2) - override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments) - override fun info(msg: String, t: Throwable?) = i(msg, t) - override fun info(marker: Marker, msg: String) = info(msg) - override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg) - override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - info(format, arg1, arg2) - - override fun info(marker: Marker?, format: String, vararg arguments: Any?) = - info(format, *arguments) - - override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t) - - override fun warn(msg: String) = w(msg) - override fun warn(format: String, arg: Any?) = w(format, null, arg) - override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2) - override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments) - override fun warn(msg: String, t: Throwable?) = w(msg, t) - override fun warn(marker: Marker, msg: String) = warn(msg) - override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg) - override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - warn(format, arg1, arg2) - - override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = - warn(format, *arguments) - - override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t) - - override fun error(msg: String) = e(msg) - override fun error(format: String, arg: Any?) = e(format, null, arg) - override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2) - override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments) - override fun error(msg: String, t: Throwable?) = e(msg, t) - override fun error(marker: Marker, msg: String) = error(msg) - override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg) - override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = - error(format, arg1, arg2) - - override fun error(marker: Marker?, format: String, vararg arguments: Any?) = - error(format, *arguments) - - override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t) -} - -object TimberLoggerFactory : LoggerFactory { - private class TimberLogger(name: String) : AbstractLogger(name) { - - // We defer the log level checks to Timber. - override fun isTraceEnabled() = true - override fun isDebugEnabled() = true - override fun isInfoEnabled() = true - override fun isWarnEnabled() = true - override fun isErrorEnabled() = true - - // Replace slf4j's "{}" format string style with standard Java's "%s". - // The supposedly redundant escape on the } is not redundant. - @Suppress("RegExpRedundantEscape") - private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s") - - override fun t(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).v(t, message.fix(), *args) - } - - override fun d(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).d(t, message.fix(), *args) - } - - override fun i(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).i(t, message.fix(), *args) - } - - override fun w(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).w(t, message.fix(), *args) - } - - override fun e(message: String, t: Throwable?, vararg args: Any?) { - Timber.tag(name).e(t, message.fix(), *args) - } - } - - override fun getLogger(name: String): Logger { - return TimberLogger(name) - } - - override fun getLogger(clazz: Class<*>): Logger { - return TimberLogger(clazz.name) - } - -} - -class SshjConfig : ConfigImpl() { - - init { - loggerFactory = TimberLoggerFactory - keepAliveProvider = KeepAliveProvider.HEARTBEAT - version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1" - - initKeyExchangeFactories() - initKeyAlgorithms() - initRandomFactory() - initFileKeyProviderFactories() - initCipherFactories() - initCompressionFactories() - initMACFactories() - } - - private fun initKeyExchangeFactories() { - keyExchangeFactories = listOf( - Curve25519SHA256.Factory(), - FactoryLibSsh(), - DHGexSHA256.Factory(), - // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get - // rsa-sha2-* key types to work with some servers (e.g. GitHub). - ExtInfoClientFactory(), - ) - } - - private fun initKeyAlgorithms() { - keyAlgorithms = listOf( - KeyAlgorithms.SSHRSACertV01(), - KeyAlgorithms.EdDSA25519(), - KeyAlgorithms.RSASHA512(), - KeyAlgorithms.RSASHA256(), - KeyAlgorithms.SSHRSA(), - KeyAlgorithms.ECDSASHANistp521(), - KeyAlgorithms.ECDSASHANistp384(), - KeyAlgorithms.ECDSASHANistp256(), - ).map { - OpenKeychainWrappedKeyAlgorithmFactory(it) - } - } - - private fun initRandomFactory() { - randomFactory = SingletonRandomFactory(JCERandom.Factory()) - } - - private fun initFileKeyProviderFactories() { - fileKeyProviderFactories = listOf( - OpenSSHKeyV1KeyFile.Factory(), - PKCS8KeyFile.Factory(), - PKCS5KeyFile.Factory(), - OpenSSHKeyFile.Factory(), - PuTTYKeyFile.Factory(), - ) - } - - - private fun initCipherFactories() { - cipherFactories = listOf( - BlockCiphers.AES256CTR(), - BlockCiphers.AES192CTR(), - BlockCiphers.AES128CTR(), - ) - } - - private fun initMACFactories() { - macFactories = listOf( - Macs.HMACSHA2512Etm(), - Macs.HMACSHA2256Etm(), - Macs.HMACSHA2512(), - Macs.HMACSHA2256(), - ) - } - - private fun initCompressionFactories() { - compressionFactories = listOf( - NoneCompression.Factory(), - ) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt b/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt deleted file mode 100644 index c36f27b3..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/git/sshj/SshjSessionFactory.kt +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.git.sshj - -import android.util.Base64 -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.w -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.git.config.AuthMode -import com.zeapo.pwdstore.git.operation.CredentialFinder -import java.io.File -import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.util.concurrent.TimeUnit -import kotlin.coroutines.Continuation -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import net.schmizz.sshj.SSHClient -import net.schmizz.sshj.common.Buffer.PlainBuffer -import net.schmizz.sshj.common.DisconnectReason -import net.schmizz.sshj.common.SSHException -import net.schmizz.sshj.common.SSHRuntimeException -import net.schmizz.sshj.common.SecurityUtils -import net.schmizz.sshj.connection.channel.direct.Session -import net.schmizz.sshj.transport.verification.FingerprintVerifier -import net.schmizz.sshj.transport.verification.HostKeyVerifier -import net.schmizz.sshj.userauth.method.AuthPassword -import net.schmizz.sshj.userauth.method.AuthPublickey -import net.schmizz.sshj.userauth.password.PasswordFinder -import net.schmizz.sshj.userauth.password.Resource -import org.eclipse.jgit.transport.CredentialsProvider -import org.eclipse.jgit.transport.RemoteSession -import org.eclipse.jgit.transport.SshSessionFactory -import org.eclipse.jgit.transport.URIish -import org.eclipse.jgit.util.FS - -sealed class SshAuthMethod(val activity: ContinuationContainerActivity) { - class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity) - class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity) - class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity) -} - -abstract class InteractivePasswordFinder : PasswordFinder { - - private var isRetry = false - - abstract fun askForPassword(cont: Continuation, 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 { - d { "New SSH connection created" } - currentSession = it - } - } - - fun close() { - currentSession?.close() - } -} - -private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier { - if (!hostKeyFile.exists()) { - return HostKeyVerifier { _, _, key -> - val digest = runCatching { - SecurityUtils.getMessageDigest("SHA-256") - }.getOrElse { e -> - throw SSHRuntimeException(e) - } - digest.update(PlainBuffer().putPublicKey(key).compactData) - val digestData = digest.digest() - val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}" - d { "Trusting host key on first use: $hostKeyEntry" } - hostKeyFile.writeText(hostKeyEntry) - true - } - } else { - val hostKeyEntry = hostKeyFile.readText() - d { "Pinned host key: $hostKeyEntry" } - return FingerprintVerifier.getInstance(hostKeyEntry) - } -} - -private class SshjSession(uri: URIish, private val username: String, private val authMethod: SshAuthMethod, private val hostKeyFile: File) : RemoteSession { - - private lateinit var ssh: SSHClient - private var currentCommand: Session? = null - - private val uri = if (uri.host.contains('@')) { - // URIish's String constructor cannot handle '@' in the user part of the URI and the URL - // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus - // need to patch everything up ourselves. - d { "Before fixup: user=${uri.user}, host=${uri.host}" } - val userPlusHost = "${uri.user}@${uri.host}" - val realUser = userPlusHost.substringBeforeLast('@') - val realHost = userPlusHost.substringAfterLast('@') - uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } } - } else { - uri - } - - fun connect(): SshjSession { - ssh = SSHClient(SshjConfig()) - ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile)) - ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) - if (!ssh.isConnected) - throw IOException() - val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password)) - when (authMethod) { - is SshAuthMethod.Password -> { - ssh.auth(username, passwordAuth) - } - is SshAuthMethod.SshKey -> { - val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey))) - ssh.auth(username, pubkeyAuth, passwordAuth) - } - is SshAuthMethod.OpenKeychain -> { - runBlocking { - OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider -> - val openKeychainAuth = AuthPublickey(provider) - ssh.auth(username, openKeychainAuth, passwordAuth) - } - } - } - } - return this - } - - override fun exec(commandName: String?, timeout: Int): Process { - if (currentCommand != null) { - w { "Killing old command" } - disconnect() - } - val session = ssh.startSession() - currentCommand = session - return SshjProcess(session.exec(commandName), timeout.toLong()) - } - - /** - * Kills the current command if one is running and returns the session into a state where `exec` - * can be called. - * - * Note that this does *not* disconnect the session. Unfortunately, the function has to be - * called `disconnect` to override the corresponding abstract function in `RemoteSession`. - */ - override fun disconnect() { - currentCommand?.close() - currentCommand = null - } - - fun close() { - disconnect() - ssh.close() - } -} - -private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() { - - override fun waitFor(): Int { - command.join(timeout, TimeUnit.SECONDS) - command.close() - return exitValue() - } - - override fun destroy() = command.close() - - override fun getOutputStream(): OutputStream = command.outputStream - - override fun getErrorStream(): InputStream = command.errorStream - - override fun exitValue(): Int = command.exitStatus - - override fun getInputStream(): InputStream = command.inputStream -} diff --git a/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt b/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt deleted file mode 100644 index 5727c4ba..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/model/PasswordEntry.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.model - -import com.github.michaelbull.result.get -import com.zeapo.pwdstore.utils.Otp -import com.zeapo.pwdstore.utils.TotpFinder -import com.zeapo.pwdstore.utils.UriTotpFinder -import java.io.ByteArrayOutputStream -import java.io.UnsupportedEncodingException -import java.util.Date - -/** - * A single entry in password store. [totpFinder] is an implementation of [TotpFinder] that let's us - * abstract out the Android-specific part and continue testing the class in the JVM. - */ -class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) { - - val password: String - val username: String? - val digits: String - val totpSecret: String? - val totpPeriod: Long - val totpAlgorithm: String - var extraContent: String - private set - - @Throws(UnsupportedEncodingException::class) - constructor(os: ByteArrayOutputStream) : this(os.toString("UTF-8"), UriTotpFinder()) - - init { - val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex())) - password = foundPassword - extraContent = passContent.joinToString("\n") - username = findUsername() - digits = findOtpDigits(content) - totpSecret = findTotpSecret(content) - totpPeriod = findTotpPeriod(content) - totpAlgorithm = findTotpAlgorithm(content) - } - - fun hasExtraContent(): Boolean { - return extraContent.isNotEmpty() - } - - fun hasTotp(): Boolean { - return totpSecret != null - } - - fun hasUsername(): Boolean { - return username != null - } - - fun calculateTotpCode(): String? { - if (totpSecret == null) - return null - return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get() - } - - val extraContentWithoutAuthData by lazy(LazyThreadSafetyMode.NONE) { - var foundUsername = false - extraContent.splitToSequence("\n").filter { line -> - return@filter when { - USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> { - foundUsername = true - false - } - line.startsWith("otpauth://", ignoreCase = true) || - line.startsWith("totp:", ignoreCase = true) -> { - false - } - else -> { - true - } - } - }.joinToString(separator = "\n") - } - - private fun findUsername(): String? { - extraContent.splitToSequence("\n").forEach { line -> - for (prefix in USERNAME_FIELDS) { - if (line.startsWith(prefix, ignoreCase = true)) - return line.substring(prefix.length).trimStart() - } - } - return null - } - - private fun findAndStripPassword(passContent: List): Pair> { - if (UriTotpFinder.TOTP_FIELDS.any { passContent[0].startsWith(it) }) return Pair("", passContent) - for (line in passContent) { - for (prefix in PASSWORD_FIELDS) { - if (line.startsWith(prefix, ignoreCase = true)) { - return Pair(line.substring(prefix.length).trimStart(), passContent.minus(line)) - } - } - } - return Pair(passContent[0], passContent.minus(passContent[0])) - } - - private fun findTotpSecret(decryptedContent: String): String? { - return totpFinder.findSecret(decryptedContent) - } - - private fun findOtpDigits(decryptedContent: String): String { - return totpFinder.findDigits(decryptedContent) - } - - private fun findTotpPeriod(decryptedContent: String): Long { - return totpFinder.findPeriod(decryptedContent) - } - - private fun findTotpAlgorithm(decryptedContent: String): String { - return totpFinder.findAlgorithm(decryptedContent) - } - - companion object { - - val USERNAME_FIELDS = arrayOf( - "login:", - "username:", - "user:", - "account:", - "email:", - "name:", - "handle:", - "id:", - "identity:" - ) - - val PASSWORD_FIELDS = arrayOf( - "password:", - "secret:", - "pass:", - ) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt b/app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt deleted file mode 100644 index babd66ca..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/pwgen/PasswordGenerator.kt +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.pwgen - -import android.content.Context -import androidx.core.content.edit -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.clearFlag -import com.zeapo.pwdstore.utils.hasFlag - -enum class PasswordOption(val key: String) { - NoDigits("0"), - NoUppercaseLetters("A"), - NoAmbiguousCharacters("B"), - FullyRandom("s"), - AtLeastOneSymbol("y"), - NoLowercaseLetters("L") -} - -object PasswordGenerator { - - const val DEFAULT_LENGTH = 16 - - const val DIGITS = 0x0001 - const val UPPERS = 0x0002 - const val SYMBOLS = 0x0004 - const val NO_AMBIGUOUS = 0x0008 - const val LOWERS = 0x0020 - - const val DIGITS_STR = "0123456789" - const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz" - const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" - const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2" - - /** - * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for - * generated passwords. - */ - fun setPrefs(ctx: Context, options: List, targetLength: Int): Boolean { - ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { - for (possibleOption in PasswordOption.values()) - putBoolean(possibleOption.key, possibleOption in options) - putInt("length", targetLength) - } - return true - } - - fun isValidPassword(password: String, pwFlags: Int): Boolean { - if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) - return false - if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) - return false - if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) - return false - if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) - return false - if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) - return false - return true - } - - /** - * Generates a password using the preferences set by [setPrefs]. - */ - @Throws(PasswordGeneratorException::class) - fun generate(ctx: Context): String { - val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) - var numCharacterCategories = 0 - - var phonemes = true - var pwgenFlags = DIGITS or UPPERS or LOWERS - - for (option in PasswordOption.values()) { - if (prefs.getBoolean(option.key, false)) { - when (option) { - PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS) - PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS) - PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS) - PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS - PasswordOption.FullyRandom -> phonemes = false - PasswordOption.AtLeastOneSymbol -> { - numCharacterCategories++ - pwgenFlags = pwgenFlags or SYMBOLS - } - } - } else { - // The No* options are false, so the respective character category will be included. - when (option) { - PasswordOption.NoDigits, - PasswordOption.NoUppercaseLetters, - PasswordOption.NoLowercaseLetters -> { - numCharacterCategories++ - } - PasswordOption.NoAmbiguousCharacters, - PasswordOption.FullyRandom, - // Since AtLeastOneSymbol is not negated, it is counted in the if branch. - PasswordOption.AtLeastOneSymbol -> { - } - } - } - } - - val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH) - if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) { - throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error)) - } - if (length < numCharacterCategories) { - throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error)) - } - if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) { - phonemes = false - pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS) - } - // Experiments show that phonemes may require more than 1000 iterations to generate a valid - // password if the length is not at least 6. - if (length < 6) { - phonemes = false - } - - var password: String? - var iterations = 0 - do { - if (iterations++ > 1000) - throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded)) - password = if (phonemes) { - RandomPhonemesGenerator.generate(length, pwgenFlags) - } else { - RandomPasswordGenerator.generate(length, pwgenFlags) - } - } while (password == null) - return password - } - - class PasswordGeneratorException(string: String) : Exception(string) -} diff --git a/app/src/main/java/com/zeapo/pwdstore/pwgen/RandomNumberGenerator.kt b/app/src/main/java/com/zeapo/pwdstore/pwgen/RandomNumberGenerator.kt deleted file mode 100644 index 6c4c956e..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/pwgen/RandomNumberGenerator.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.pwgen - -import java.security.SecureRandom - -private val secureRandom = SecureRandom() - -/** - * Returns a number between 0 (inclusive) and [exclusiveBound] (exclusive). - */ -fun secureRandomNumber(exclusiveBound: Int) = secureRandom.nextInt(exclusiveBound) - -/** - * Returns `true` and `false` with probablity 50% each. - */ -fun secureRandomBoolean() = secureRandom.nextBoolean() - -/** - * Returns `true` with probability [percentTrue]% and `false` with probability - * `(100 - [percentTrue])`%. - */ -fun secureRandomBiasedBoolean(percentTrue: Int): Boolean { - require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" } - require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" } - return secureRandomNumber(100) < percentTrue -} - -fun Array.secureRandomElement() = this[secureRandomNumber(size)] -fun List.secureRandomElement() = this[secureRandomNumber(size)] -fun String.secureRandomCharacter() = this[secureRandomNumber(length)] diff --git a/app/src/main/java/com/zeapo/pwdstore/pwgen/RandomPasswordGenerator.kt b/app/src/main/java/com/zeapo/pwdstore/pwgen/RandomPasswordGenerator.kt deleted file mode 100644 index 67d8fc40..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/pwgen/RandomPasswordGenerator.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.pwgen - -import com.zeapo.pwdstore.utils.hasFlag - -object RandomPasswordGenerator { - - /** - * Generates a random password of length [targetLength], taking the following flags in [pwFlags] - * into account, or fails to do so and returns null: - * - * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not - * set, the password will not contain any digits. - * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase - * letter; if not set, the password will not contain any uppercase letters. - * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase - * letter; if not set, the password will not contain any lowercase letters. - * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not - * set, the password will not contain any symbols. - * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous - * characters. - */ - fun generate(targetLength: Int, pwFlags: Int): String? { - val bank = listOfNotNull( - PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS }, - PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS }, - PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS }, - PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS }, - ).joinToString("") - - var password = "" - while (password.length < targetLength) { - val candidate = bank.secureRandomCharacter() - if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && - candidate in PasswordGenerator.AMBIGUOUS_STR) { - continue - } - password += candidate - } - return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/pwgen/RandomPhonemesGenerator.kt b/app/src/main/java/com/zeapo/pwdstore/pwgen/RandomPhonemesGenerator.kt deleted file mode 100644 index 6687293f..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/pwgen/RandomPhonemesGenerator.kt +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.pwgen - -import com.zeapo.pwdstore.utils.hasFlag -import java.util.Locale - -object RandomPhonemesGenerator { - - private const val CONSONANT = 0x0001 - private const val VOWEL = 0x0002 - private const val DIPHTHONG = 0x0004 - private const val NOT_FIRST = 0x0008 - - private val elements = arrayOf( - Element("a", VOWEL), - Element("ae", VOWEL or DIPHTHONG), - Element("ah", VOWEL or DIPHTHONG), - Element("ai", VOWEL or DIPHTHONG), - Element("b", CONSONANT), - Element("c", CONSONANT), - Element("ch", CONSONANT or DIPHTHONG), - Element("d", CONSONANT), - Element("e", VOWEL), - Element("ee", VOWEL or DIPHTHONG), - Element("ei", VOWEL or DIPHTHONG), - Element("f", CONSONANT), - Element("g", CONSONANT), - Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST), - Element("h", CONSONANT), - Element("i", VOWEL), - Element("ie", VOWEL or DIPHTHONG), - Element("j", CONSONANT), - Element("k", CONSONANT), - Element("l", CONSONANT), - Element("m", CONSONANT), - Element("n", CONSONANT), - Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST), - Element("o", VOWEL), - Element("oh", VOWEL or DIPHTHONG), - Element("oo", VOWEL or DIPHTHONG), - Element("p", CONSONANT), - Element("ph", CONSONANT or DIPHTHONG), - Element("qu", CONSONANT or DIPHTHONG), - Element("r", CONSONANT), - Element("s", CONSONANT), - Element("sh", CONSONANT or DIPHTHONG), - Element("t", CONSONANT), - Element("th", CONSONANT or DIPHTHONG), - Element("u", VOWEL), - Element("v", CONSONANT), - Element("w", CONSONANT), - Element("x", CONSONANT), - Element("y", CONSONANT), - Element("z", CONSONANT) - ) - - private class Element(str: String, val flags: Int) { - - val upperCase = str.toUpperCase(Locale.ROOT) - val lowerCase = str.toLowerCase(Locale.ROOT) - val length = str.length - val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR } - } - - /** - * Generates a random human-readable password of length [targetLength], taking the following - * flags in [pwFlags] into account, or fails to do so and returns null: - * - * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not - * set, the password will not contain any digits. - * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase - * letter; if not set, the password will not contain any uppercase letters. - * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase - * letter; if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any - * lowercase characters; if both are not set, an exception is thrown. - * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not - * set, the password will not contain any symbols. - * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous - * characters. - */ - fun generate(targetLength: Int, pwFlags: Int): String? { - require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS) - - var password = "" - - var isStartOfPart = true - var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT - var previousFlags = 0 - - while (password.length < targetLength) { - // First part: Add a single letter or pronounceable pair of letters in varying case. - - val candidate = elements.secureRandomElement() - - // Reroll if the candidate does not fulfill the current requirements. - if (!candidate.flags.hasFlag(nextBasicType) || - (isStartOfPart && candidate.flags hasFlag NOT_FIRST) || - // Don't let a diphthong that starts with a vowel follow a vowel. - (previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) || - // Don't add multi-character candidates if we would go over the targetLength. - (password.length + candidate.length > targetLength) || - (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)) { - continue - } - - // At this point the candidate could be appended to the password, but we still have - // to determine the case. If no upper case characters are required, we don't add - // any. - val useUpperIfBothCasesAllowed = - (isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20) - password += if (pwFlags hasFlag PasswordGenerator.UPPERS && - (!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)) { - candidate.upperCase - } else { - candidate.lowerCase - } - - // We ensured above that we will not go above the target length. - check(password.length <= targetLength) - if (password.length == targetLength) - break - - // Second part: Add digits and symbols with a certain probability (if requested) if - // they would not directly follow the first character in a pronounceable part. - - if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS && - secureRandomBiasedBoolean(30)) { - var randomDigit: Char - do { - randomDigit = secureRandomNumber(10).toString(10).first() - } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && - randomDigit in PasswordGenerator.AMBIGUOUS_STR) - - password += randomDigit - // Begin a new pronounceable part after every digit. - isStartOfPart = true - nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT - previousFlags = 0 - continue - } - - if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS && - secureRandomBiasedBoolean(20)) { - var randomSymbol: Char - do { - randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter() - } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && - randomSymbol in PasswordGenerator.AMBIGUOUS_STR) - password += randomSymbol - // Continue the password generation as if nothing was added. - } - - // Third part: Determine the basic type of the next character depending on the letter - // we just added. - nextBasicType = when { - candidate.flags.hasFlag(CONSONANT) -> VOWEL - previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) || - secureRandomBiasedBoolean(60) -> CONSONANT - else -> VOWEL - } - previousFlags = candidate.flags - isStartOfPart = false - } - return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/CapsType.kt b/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/CapsType.kt deleted file mode 100644 index f98a9f10..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/CapsType.kt +++ /dev/null @@ -1,9 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.pwgenxkpwd - -enum class CapsType { - lowercase, UPPERCASE, TitleCase, Sentence, As_iS -} diff --git a/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/PasswordBuilder.kt b/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/PasswordBuilder.kt deleted file mode 100644 index 398a955d..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/PasswordBuilder.kt +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.pwgenxkpwd - -import android.content.Context -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.pwgen.PasswordGenerator.PasswordGeneratorException -import com.zeapo.pwdstore.pwgen.secureRandomCharacter -import com.zeapo.pwdstore.pwgen.secureRandomElement -import com.zeapo.pwdstore.pwgen.secureRandomNumber -import java.util.Locale - -class PasswordBuilder(ctx: Context) { - - private var numSymbols = 0 - private var isAppendSymbolsSeparator = false - private var context = ctx - private var numWords = 3 - private var maxWordLength = 9 - private var minWordLength = 5 - private var separator = "." - private var capsType = CapsType.Sentence - private var prependDigits = 0 - private var numDigits = 0 - private var isPrependWithSeparator = false - private var isAppendNumberSeparator = false - - fun setNumberOfWords(amount: Int) = apply { - numWords = amount - } - - fun setMinimumWordLength(min: Int) = apply { - minWordLength = min - } - - fun setMaximumWordLength(max: Int) = apply { - maxWordLength = max - } - - fun setSeparator(separator: String) = apply { - this.separator = separator - } - - fun setCapitalization(capitalizationScheme: CapsType) = apply { - capsType = capitalizationScheme - } - - @JvmOverloads - fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply { - prependDigits = numDigits - isPrependWithSeparator = addSeparator - } - - @JvmOverloads - fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply { - this.numDigits = numDigits - isAppendNumberSeparator = addSeparator - } - - @JvmOverloads - fun appendSymbols(numSymbols: Int, addSeparator: Boolean = false) = apply { - this.numSymbols = numSymbols - isAppendSymbolsSeparator = addSeparator - } - - private fun generateRandomNumberSequence(totalNumbers: Int): String { - val numbers = StringBuilder(totalNumbers) - for (i in 0 until totalNumbers) { - numbers.append(secureRandomNumber(10)) - } - return numbers.toString() - } - - private fun generateRandomSymbolSequence(numSymbols: Int): String { - val numbers = StringBuilder(numSymbols) - for (i in 0 until numSymbols) { - numbers.append(SYMBOLS.secureRandomCharacter()) - } - return numbers.toString() - } - - @OptIn(ExperimentalStdlibApi::class) - fun create(): Result { - val wordBank = mutableListOf() - val password = StringBuilder() - - if (prependDigits != 0) { - password.append(generateRandomNumberSequence(prependDigits)) - if (isPrependWithSeparator) { - password.append(separator) - } - } - return runCatching { - val dictionary = XkpwdDictionary(context) - val words = dictionary.words - for (wordLength in minWordLength..maxWordLength) { - wordBank.addAll(words[wordLength] ?: emptyList()) - } - - if (wordBank.size == 0) { - throw PasswordGeneratorException(context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength)) - } - - for (i in 0 until numWords) { - val candidate = wordBank.secureRandomElement() - val s = when (capsType) { - CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault()) - CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate - CapsType.TitleCase -> candidate.capitalize(Locale.getDefault()) - CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault()) - CapsType.As_iS -> candidate - } - password.append(s) - if (i + 1 < numWords) { - password.append(separator) - } - } - if (numDigits != 0) { - if (isAppendNumberSeparator) { - password.append(separator) - } - password.append(generateRandomNumberSequence(numDigits)) - } - if (numSymbols != 0) { - if (isAppendSymbolsSeparator) { - password.append(separator) - } - password.append(generateRandomSymbolSequence(numSymbols)) - } - password.toString() - } - } - - companion object { - - private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#" - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt b/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt deleted file mode 100644 index 2d0fbee9..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/pwgenxkpwd/XkpwdDictionary.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.pwgenxkpwd - -import android.content.Context -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.sharedPrefs -import java.io.File - -class XkpwdDictionary(context: Context) { - - val words: Map> - - init { - val prefs = context.sharedPrefs - val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: "" - val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE) - - val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) && - uri.isNotEmpty() && customDictFile.canRead()) { - customDictFile.readLines() - } else { - context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines() - } - - words = lines.asSequence() - .map { it.trim() } - .filter { it.isNotEmpty() && !it.contains(' ') } - .groupBy { it.length } - } - - companion object { - - const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt" - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt deleted file mode 100644 index bfa7e1c8..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/ShowSshKeyFragment.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.sshkeygen - -import android.app.Dialog -import android.content.Intent -import android.os.Bundle -import androidx.fragment.app.DialogFragment -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.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/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt deleted file mode 100644 index d2fdab61..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/sshkeygen/SshKeyGenActivity.kt +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.sshkeygen - -import android.os.Bundle -import android.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 com.zeapo.pwdstore.R -import com.zeapo.pwdstore.databinding.ActivitySshKeygenBinding -import com.zeapo.pwdstore.git.sshj.SshKey -import com.zeapo.pwdstore.utils.BiometricAuthenticator -import com.zeapo.pwdstore.utils.getEncryptedGitPrefs -import com.zeapo.pwdstore.utils.keyguardManager -import com.zeapo.pwdstore.utils.viewBinding -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { - Rsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) - }), - Ecdsa({ requireAuthentication -> - SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) - }), - Ed25519({ requireAuthentication -> - SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) - }), -} - -class SshKeyGenActivity : AppCompatActivity() { - - private var keyGenType = KeyGenType.Ecdsa - private val binding by viewBinding(ActivitySshKeygenBinding::inflate) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - with(binding) { - generate.setOnClickListener { - if (SshKey.exists) { - MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { - setTitle(R.string.ssh_keygen_existing_title) - setMessage(R.string.ssh_keygen_existing_message) - setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> - lifecycleScope.launch { - generate() - } - } - setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> - finish() - } - show() - } - } else { - lifecycleScope.launch { - generate() - } - } - } - keyTypeGroup.check(R.id.key_type_ecdsa) - keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) - keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> - if (isChecked) { - keyGenType = when (checkedId) { - R.id.key_type_ed25519 -> KeyGenType.Ed25519 - R.id.key_type_ecdsa -> KeyGenType.Ecdsa - R.id.key_type_rsa -> KeyGenType.Rsa - else -> throw IllegalStateException("Impossible key type selection") - } - keyTypeExplanation.setText(when (keyGenType) { - KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519 - KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa - KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa - }) - } - } - keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure - keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - // The back arrow in the action bar should act the same as the back button. - return when (item.itemId) { - android.R.id.home -> { - onBackPressed() - true - } - else -> super.onOptionsItemSelected(item) - } - } - - private suspend fun generate() { - binding.generate.apply { - text = getString(R.string.ssh_key_gen_generating_progress) - isEnabled = false - } - binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) - val result = runCatching { - withContext(Dispatchers.IO) { - val requireAuthentication = binding.keyRequireAuthentication.isChecked - if (requireAuthentication) { - val result = withContext(Dispatchers.Main) { - suspendCoroutine { cont -> - BiometricAuthenticator.authenticate(this@SshKeyGenActivity, R.string.biometric_prompt_title_ssh_keygen) { - cont.resume(it) - } - } - } - if (result !is BiometricAuthenticator.Result.Success) - throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure)) - } - keyGenType.generateKey(requireAuthentication) - } - } - getEncryptedGitPrefs().edit { - remove("ssh_key_local_passphrase") - } - binding.generate.apply { - text = getString(R.string.ssh_keygen_generate) - isEnabled = true - } - result.fold( - success = { - ShowSshKeyFragment().show(supportFragmentManager, "public_key") - }, - failure = { e -> - e.printStackTrace() - MaterialAlertDialogBuilder(this) - .setTitle(getString(R.string.error_generate_ssh_key)) - .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message) - .setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> - finish() - } - .show() - }, - ) - hideKeyboard() - } - - private fun hideKeyboard() { - val imm = getSystemService() ?: return - var view = currentFocus - if (view == null) { - view = View(this) - } - imm.hideSoftInputFromWindow(view.windowToken, 0) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/OnOffItemAnimator.kt b/app/src/main/java/com/zeapo/pwdstore/ui/OnOffItemAnimator.kt deleted file mode 100644 index e394e656..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/OnOffItemAnimator.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.ui - -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/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt deleted file mode 100644 index 38315876..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/adapters/PasswordItemRecyclerAdapter.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.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 com.zeapo.pwdstore.R -import com.zeapo.pwdstore.SearchableRepositoryAdapter -import com.zeapo.pwdstore.stableId -import com.zeapo.pwdstore.utils.PasswordItem - -open class PasswordItemRecyclerAdapter : - SearchableRepositoryAdapter( - R.layout.password_row_layout, - ::PasswordItemViewHolder, - PasswordItemViewHolder::bind - ) { - - fun makeSelectable(recyclerView: RecyclerView) { - makeSelectable(recyclerView, ::PasswordItemDetailsLookup) - } - - override fun onItemClicked(listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit): PasswordItemRecyclerAdapter { - return super.onItemClicked(listener) as PasswordItemRecyclerAdapter - } - - override fun onSelectionChanged(listener: (selection: Selection) -> 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 - - fun bind(item: PasswordItem) { - val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") - val source = if (parentPath.isNotEmpty()) { - "$parentPath\n$item" - } else { - "$item" - } - val spannable = SpannableString(source) - spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0) - name.text = spannable - if (item.type == PasswordItem.TYPE_CATEGORY) { - folderIndicator.visibility = View.VISIBLE - val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0 - childCount.visibility = if (count > 0) View.VISIBLE else View.GONE - childCount.text = "$count" - } else { - childCount.visibility = View.GONE - folderIndicator.visibility = View.GONE - } - itemDetails = object : ItemDetailsLookup.ItemDetails() { - 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/com/zeapo/pwdstore/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/BasicBottomSheet.kt deleted file mode 100644 index 6f028009..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/BasicBottomSheet.kt +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.ui.dialogs - -import android.content.Context -import android.graphics.drawable.GradientDrawable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewTreeObserver -import android.widget.FrameLayout -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.databinding.BasicBottomSheetBinding -import com.zeapo.pwdstore.utils.resolveAttribute -import com.zeapo.pwdstore.utils.viewBinding - -/** - * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like - * API through [Builder] to create a similar UI, just at the bottom of the screen. - */ -class BasicBottomSheet private constructor( - val title: String?, - val message: String, - val positiveButtonLabel: String?, - val negativeButtonLabel: String?, - val positiveButtonClickListener: View.OnClickListener?, - val negativeButtonClickListener: View.OnClickListener?, -) : BottomSheetDialogFragment() { - - private val binding by viewBinding(BasicBottomSheetBinding::bind) - - private var behavior: BottomSheetBehavior? = null - private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { - override fun onSlide(bottomSheet: View, slideOffset: Float) { - } - - override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == BottomSheetBehavior.STATE_COLLAPSED) { - dismiss() - } - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { - if (savedInstanceState != null) dismiss() - return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { - override fun onGlobalLayout() { - view.viewTreeObserver.removeOnGlobalLayoutListener(this) - val dialog = dialog as BottomSheetDialog? ?: return - behavior = dialog.behavior - behavior?.apply { - state = BottomSheetBehavior.STATE_EXPANDED - peekHeight = 0 - addBottomSheetCallback(bottomSheetCallback) - } - if (!title.isNullOrEmpty()) { - binding.bottomSheetTitle.isVisible = true - binding.bottomSheetTitle.text = title - } - binding.bottomSheetMessage.text = message - if (positiveButtonClickListener != null) { - positiveButtonLabel?.let { buttonLbl -> - binding.bottomSheetOkButton.text = buttonLbl - } - binding.bottomSheetOkButton.isVisible = true - binding.bottomSheetOkButton.setOnClickListener { - positiveButtonClickListener.onClick(it) - dismiss() - } - } - if (negativeButtonClickListener != null) { - binding.bottomSheetCancelButton.isVisible = true - negativeButtonLabel?.let { buttonLbl -> - binding.bottomSheetCancelButton.text = buttonLbl - } - binding.bottomSheetCancelButton.setOnClickListener { - negativeButtonClickListener.onClick(it) - dismiss() - } - } - } - }) - val gradientDrawable = GradientDrawable().apply { - setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) - } - view.background = gradientDrawable - } - - override fun dismiss() { - super.dismiss() - behavior?.removeBottomSheetCallback(bottomSheetCallback) - } - - class Builder(val context: Context) { - - private var title: String? = null - private var message: String? = null - private var positiveButtonLabel: String? = null - private var negativeButtonLabel: String? = null - private var positiveButtonClickListener: View.OnClickListener? = null - private var negativeButtonClickListener: View.OnClickListener? = null - - fun setTitleRes(@StringRes titleRes: Int): Builder { - this.title = context.resources.getString(titleRes) - return this - } - - fun setTitle(title: String): Builder { - this.title = title - return this - } - - fun setMessageRes(@StringRes messageRes: Int): Builder { - this.message = context.resources.getString(messageRes) - return this - } - - fun setMessage(message: String): Builder { - this.message = message - return this - } - - fun setPositiveButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder { - this.positiveButtonClickListener = listener - this.positiveButtonLabel = buttonLabel - return this - } - - fun setNegativeButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder { - this.negativeButtonClickListener = listener - this.negativeButtonLabel = buttonLabel - return this - } - - fun build(): BasicBottomSheet { - require(message != null) { "Message needs to be set" } - return BasicBottomSheet( - title, - message!!, - positiveButtonLabel, - negativeButtonLabel, - positiveButtonClickListener, - negativeButtonClickListener - ) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt deleted file mode 100644 index d5dd9b43..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/FolderCreationDialogFragment.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.ui.dialogs - -import android.app.Dialog -import android.content.Intent -import android.os.Bundle -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity -import androidx.core.os.bundleOf -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.lifecycleScope -import com.google.android.material.checkbox.MaterialCheckBox -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.textfield.TextInputEditText -import com.google.android.material.textfield.TextInputLayout -import com.zeapo.pwdstore.PasswordStore -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.crypto.BasePgpActivity -import com.zeapo.pwdstore.crypto.GetKeyIdsActivity -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.commitChange -import com.zeapo.pwdstore.utils.requestInputFocusOnView -import java.io.File -import kotlinx.coroutines.launch -import me.msfjarvis.openpgpktx.util.OpenPgpApi - -class FolderCreationDialogFragment : DialogFragment() { - - private lateinit var newFolder: File - - private val keySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> - val gpgIdentifierFile = File(newFolder, ".gpg-id") - gpgIdentifierFile.writeText(keyIds.joinToString("\n")) - val repo = PasswordRepository.getRepository(null) - if (repo != null) { - lifecycleScope.launch { - val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath - requireActivity().commitChange( - getString( - R.string.git_commit_gpg_id, - BasePgpActivity.getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) - ), - ) - dismiss() - } - } - } - } - } - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext()) - alertDialogBuilder.setTitle(R.string.title_create_folder) - alertDialogBuilder.setView(R.layout.folder_dialog_fragment) - alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null) - alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> - dismiss() - } - val dialog = alertDialogBuilder.create() - dialog.requestInputFocusOnView(R.id.folder_name_text) - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { - createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!) - } - } - return dialog - } - - private fun createDirectory(currentDir: String) { - val dialog = requireDialog() - val folderNameView = dialog.findViewById(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/com/zeapo/pwdstore/ui/dialogs/ItemCreationBottomSheet.kt b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/ItemCreationBottomSheet.kt deleted file mode 100644 index 5acc8091..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/ItemCreationBottomSheet.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.ui.dialogs - -import android.graphics.drawable.GradientDrawable -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.ViewTreeObserver -import android.widget.FrameLayout -import androidx.core.os.bundleOf -import androidx.fragment.app.setFragmentResult -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.zeapo.pwdstore.PasswordFragment.Companion.ACTION_FOLDER -import com.zeapo.pwdstore.PasswordFragment.Companion.ACTION_KEY -import com.zeapo.pwdstore.PasswordFragment.Companion.ACTION_PASSWORD -import com.zeapo.pwdstore.PasswordFragment.Companion.ITEM_CREATION_REQUEST_KEY -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.utils.resolveAttribute - -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() - } - } - }) - val gradientDrawable = GradientDrawable().apply { - setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) - } - view.background = gradientDrawable - } - - override fun dismiss() { - super.dismiss() - behavior?.removeBottomSheetCallback(bottomSheetCallback) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt deleted file mode 100644 index 7aa66a03..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/PasswordGeneratorDialogFragment.kt +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.ui.dialogs - -import android.annotation.SuppressLint -import android.app.AlertDialog -import android.app.Dialog -import android.content.Context -import android.graphics.Typeface -import android.os.Bundle -import android.widget.CheckBox -import android.widget.EditText -import android.widget.Toast -import androidx.annotation.IdRes -import androidx.appcompat.widget.AppCompatEditText -import androidx.appcompat.widget.AppCompatTextView -import androidx.fragment.app.DialogFragment -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.pwgen.PasswordGenerator -import com.zeapo.pwdstore.pwgen.PasswordGenerator.generate -import com.zeapo.pwdstore.pwgen.PasswordGenerator.setPrefs -import com.zeapo.pwdstore.pwgen.PasswordOption -import com.zeapo.pwdstore.utils.PreferenceKeys - -class PasswordGeneratorDialogFragment : DialogFragment() { - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val callingActivity = requireActivity() - val inflater = callingActivity.layoutInflater - - @SuppressLint("InflateParams") - val view = inflater.inflate(R.layout.fragment_pwgen, null) - val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf") - val prefs = requireActivity().applicationContext - .getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) - - view.findViewById(R.id.numerals)?.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false) - view.findViewById(R.id.symbols)?.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false) - view.findViewById(R.id.uppercase)?.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false) - view.findViewById(R.id.lowercase)?.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false) - view.findViewById(R.id.ambiguous)?.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false) - view.findViewById(R.id.pronounceable)?.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true) - - val textView: AppCompatEditText = view.findViewById(R.id.lengthNumber) - textView.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString()) - val passwordText: AppCompatTextView = view.findViewById(R.id.passwordText) - passwordText.typeface = monoTypeface - return MaterialAlertDialogBuilder(requireContext()).run { - setTitle(R.string.pwgen_title) - setView(view) - setPositiveButton(R.string.dialog_ok) { _, _ -> - val edit = callingActivity.findViewById(R.id.password) - edit.setText(passwordText.text) - } - setNeutralButton(R.string.dialog_cancel) { _, _ -> } - setNegativeButton(R.string.pwgen_generate, null) - create() - }.apply { - setOnShowListener { - generate(passwordText) - getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { - generate(passwordText) - } - } - } - } - - private fun generate(passwordField: AppCompatTextView) { - setPreferences() - passwordField.text = runCatching { - generate(requireContext().applicationContext) - }.getOrElse { e -> - Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show() - "" - } - } - - private fun isChecked(@IdRes id: Int): Boolean { - return requireDialog().findViewById(id).isChecked - } - - private fun setPreferences() { - val preferences = listOfNotNull( - PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, - PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) }, - PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, - PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) }, - PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) }, - PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) } - ) - val lengthText = requireDialog().findViewById(R.id.lengthNumber).text.toString() - val length = lengthText.toIntOrNull()?.takeIf { it >= 0 } - ?: PasswordGenerator.DEFAULT_LENGTH - setPrefs(requireActivity().applicationContext, preferences, length) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/XkPasswordGeneratorDialogFragment.kt b/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/XkPasswordGeneratorDialogFragment.kt deleted file mode 100644 index 274330bb..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/dialogs/XkPasswordGeneratorDialogFragment.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.ui.dialogs - -import android.app.Dialog -import android.content.Context -import android.content.SharedPreferences -import android.graphics.Typeface -import android.os.Bundle -import android.widget.EditText -import android.widget.Toast -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.widget.AppCompatTextView -import androidx.core.content.edit -import androidx.fragment.app.DialogFragment -import com.github.ajalt.timberkt.Timber.tag -import com.github.michaelbull.result.fold -import com.github.michaelbull.result.getOr -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.databinding.FragmentXkpwgenBinding -import com.zeapo.pwdstore.pwgenxkpwd.CapsType -import com.zeapo.pwdstore.pwgenxkpwd.PasswordBuilder -import com.zeapo.pwdstore.utils.getString - -/** A placeholder fragment containing a simple view. */ -class XkPasswordGeneratorDialogFragment : DialogFragment() { - - private lateinit var prefs: SharedPreferences - private lateinit var binding: FragmentXkpwgenBinding - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - val callingActivity = requireActivity() - val inflater = callingActivity.layoutInflater - binding = FragmentXkpwgenBinding.inflate(inflater) - - val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf") - - builder.setView(binding.root) - - prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) - - val previousStoredCapStyle: String = runCatching { - prefs.getString(PREF_KEY_CAPITALS_STYLE)!! - }.getOr(DEFAULT_CAPS_STYLE) - - val lastCapitalsStyleIndex: Int = runCatching { - CapsType.valueOf(previousStoredCapStyle).ordinal - }.getOr(DEFAULT_CAPS_INDEX) - binding.xkCapType.setSelection(lastCapitalsStyleIndex) - binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS)) - - binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR)) - binding.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK)) - - binding.xkPasswordText.typeface = monoTypeface - - builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> - setPreferences() - val edit = callingActivity.findViewById(R.id.password) - edit.setText(binding.xkPasswordText.text) - } - - // flip neutral and negative buttons - builder.setNeutralButton(resources.getString(R.string.dialog_cancel)) { _, _ -> } - builder.setNegativeButton(resources.getString(R.string.pwgen_generate), null) - - val dialog = builder.setTitle(this.resources.getString(R.string.xkpwgen_title)).create() - - dialog.setOnShowListener { - setPreferences() - makeAndSetPassword(binding.xkPasswordText) - - dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { - setPreferences() - makeAndSetPassword(binding.xkPasswordText) - } - } - return dialog - } - - private fun makeAndSetPassword(passwordText: AppCompatTextView) { - PasswordBuilder(requireContext()) - .setNumberOfWords(Integer.valueOf(binding.xkNumWords.text.toString())) - .setMinimumWordLength(DEFAULT_MIN_WORD_LENGTH) - .setMaximumWordLength(DEFAULT_MAX_WORD_LENGTH) - .setSeparator(binding.xkSeparator.text.toString()) - .appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT }) - .appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL }) - .setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString())).create() - .fold( - success = { passwordText.text = it }, - failure = { e -> - Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show() - tag("xkpw").e(e, "failure generating xkpasswd") - passwordText.text = FALLBACK_ERROR_PASS - }, - ) - } - - private fun setPreferences() { - prefs.edit { - putString(PREF_KEY_CAPITALS_STYLE, binding.xkCapType.selectedItem.toString()) - putString(PREF_KEY_NUM_WORDS, binding.xkNumWords.text.toString()) - putString(PREF_KEY_SEPARATOR, binding.xkSeparator.text.toString()) - putString(PREF_KEY_EXTRA_SYMBOLS_MASK, binding.xkNumberSymbolMask.text.toString()) - } - } - - companion object { - - const val PREF_KEY_CAPITALS_STYLE = "pref_key_capitals_style" - const val PREF_KEY_NUM_WORDS = "pref_key_num_words" - const val PREF_KEY_SEPARATOR = "pref_key_separator" - const val PREF_KEY_EXTRA_SYMBOLS_MASK = "pref_key_xkpwgen_extra_symbols_mask" - val DEFAULT_CAPS_STYLE = CapsType.Sentence.name - val DEFAULT_CAPS_INDEX = CapsType.Sentence.ordinal - const val DEFAULT_NUMBER_OF_WORDS = "3" - const val DEFAULT_WORD_SEPARATOR = "." - const val DEFAULT_EXTRA_SYMBOLS_MASK = "ds" - const val DEFAULT_MIN_WORD_LENGTH = 3 - const val DEFAULT_MAX_WORD_LENGTH = 9 - const val FALLBACK_ERROR_PASS = "42" - const val EXTRA_CHAR_PLACEHOLDER_DIGIT = 'd' - const val EXTRA_CHAR_PLACEHOLDER_SYMBOL = 's' - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/activity/OnboardingActivity.kt b/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/activity/OnboardingActivity.kt deleted file mode 100644 index b81efb4c..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/activity/OnboardingActivity.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.ui.onboarding.activity - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import com.zeapo.pwdstore.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/com/zeapo/pwdstore/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/CloneFragment.kt deleted file mode 100644 index acd0151c..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/CloneFragment.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - * - */ - -package com.zeapo.pwdstore.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.zeapo.pwdstore.R -import com.zeapo.pwdstore.databinding.FragmentCloneBinding -import com.zeapo.pwdstore.git.GitServerConfigActivity -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.finish -import com.zeapo.pwdstore.utils.performTransactionWithBackStack -import com.zeapo.pwdstore.utils.sharedPrefs -import com.zeapo.pwdstore.utils.viewBinding - -class CloneFragment : Fragment(R.layout.fragment_clone) { - - private val binding by viewBinding(FragmentCloneBinding::bind) - - private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } - - private val cloneAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } - finish() - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.cloneRemote.setOnClickListener { - cloneToHiddenDir() - } - binding.createLocal.setOnClickListener { - parentFragmentManager.performTransactionWithBackStack(RepoLocationFragment.newInstance()) - } - } - - /** - * Clones a remote Git repository to the app's private directory - */ - private fun cloneToHiddenDir() { - settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) } - cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext())) - } - - companion object { - - fun newInstance(): CloneFragment = CloneFragment() - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/KeySelectionFragment.kt deleted file mode 100644 index 3dc03954..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/KeySelectionFragment.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.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.zeapo.pwdstore.R -import com.zeapo.pwdstore.crypto.GetKeyIdsActivity -import com.zeapo.pwdstore.databinding.FragmentKeySelectionBinding -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.commitChange -import com.zeapo.pwdstore.utils.finish -import com.zeapo.pwdstore.utils.sharedPrefs -import com.zeapo.pwdstore.utils.viewBinding -import java.io.File -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import me.msfjarvis.openpgpktx.util.OpenPgpApi - -class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) { - - private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } - private val binding by viewBinding(FragmentKeySelectionBinding::bind) - - private val gpgKeySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> - lifecycleScope.launch { - withContext(Dispatchers.IO) { - val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") - gpgIdentifierFile.writeText(keyIds.joinToString("\n")) - } - settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } - requireActivity().commitChange(getString( - R.string.git_commit_gpg_id, - getString(R.string.app_name) - )) - } - } - } else { - throw IllegalStateException("Failed to initialize repository state.") - } - finish() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.selectKey.setOnClickListener { gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) } - } - - companion object { - - fun newInstance() = KeySelectionFragment() - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/RepoLocationFragment.kt b/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/RepoLocationFragment.kt deleted file mode 100644 index bea0ce3e..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/RepoLocationFragment.kt +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - * - */ - -package com.zeapo.pwdstore.ui.onboarding.fragments - -import android.Manifest -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.fragment.app.Fragment -import com.github.ajalt.timberkt.d -import com.github.ajalt.timberkt.e -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.UserPreference -import com.zeapo.pwdstore.databinding.FragmentRepoLocationBinding -import com.zeapo.pwdstore.utils.PasswordRepository -import com.zeapo.pwdstore.utils.PasswordSortOrder -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.finish -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.isPermissionGranted -import com.zeapo.pwdstore.utils.listFilesRecursively -import com.zeapo.pwdstore.utils.performTransactionWithBackStack -import com.zeapo.pwdstore.utils.sharedPrefs -import com.zeapo.pwdstore.utils.viewBinding -import java.io.File - -class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) { - - private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } - private val binding by viewBinding(FragmentRepoLocationBinding::bind) - private val sortOrder: PasswordSortOrder - get() = PasswordSortOrder.getSortOrder(settings) - - private val repositoryInitAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - initializeRepositoryInfo() - } - } - - private val externalDirectorySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> - if (result.resultCode == AppCompatActivity.RESULT_OK) { - if (checkExternalDirectory()) { - finish() - } else { - createRepository() - } - } - } - - private val externalDirPermGrantedAction = createPermGrantedAction { - externalDirectorySelectAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) - } - - private val repositoryUsePermGrantedAction = createPermGrantedAction { - initializeRepositoryInfo() - } - - private val repositoryChangePermGrantedAction = createPermGrantedAction { - repositoryInitAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.hidden.setOnClickListener { - createRepoInHiddenDir() - } - - binding.sdcard.setOnClickListener { - createRepoFromExternalDir() - } - } - - /** - * Initializes an empty repository in the app's private directory - */ - private fun createRepoInHiddenDir() { - settings.edit { - putBoolean(PreferenceKeys.GIT_EXTERNAL, false) - remove(PreferenceKeys.GIT_EXTERNAL_REPO) - } - initializeRepositoryInfo() - } - - /** - * Initializes an empty repository in a selected directory if one does not already exist - */ - private fun createRepoFromExternalDir() { - settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) } - val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) - if (externalRepo == null) { - if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - externalDirPermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } else { - // Unlikely we have storage permissions without user ever selecting a directory, - // but let's not assume. - externalDirectorySelectAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) - } - } else { - MaterialAlertDialogBuilder(requireActivity()) - .setTitle(resources.getString(R.string.directory_selected_title)) - .setMessage(resources.getString(R.string.directory_selected_message, externalRepo)) - .setPositiveButton(resources.getString(R.string.use)) { _, _ -> - if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - repositoryUsePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } else { - initializeRepositoryInfo() - } - } - .setNegativeButton(resources.getString(R.string.change)) { _, _ -> - if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - repositoryChangePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } else { - repositoryInitAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) - } - } - .show() - } - } - - private fun checkExternalDirectory(): Boolean { - if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) && - settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null) { - val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) - val dir = externalRepoPath?.let { File(it) } - if (dir != null && // The directory could be opened - dir.exists() && // The directory exists - dir.isDirectory && // The directory, is really a directory - dir.listFilesRecursively().isNotEmpty() && // The directory contains files - // The directory contains a non-zero number of password files - PasswordRepository.getPasswords(dir, PasswordRepository.getRepositoryDirectory(), sortOrder).isNotEmpty() - ) { - PasswordRepository.closeRepository() - return true - } - } - return false - } - - private fun createRepository() { - val localDir = PasswordRepository.getRepositoryDirectory() - runCatching { - check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" } - PasswordRepository.createRepository(localDir) - if (!PasswordRepository.isInitialized) { - PasswordRepository.initialize() - } - parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance()) - }.onFailure { e -> - e(e) - if (!localDir.delete()) { - d { "Failed to delete local repository: $localDir" } - } - finish() - } - } - - private fun initializeRepositoryInfo() { - val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) - val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) - if (externalRepo && !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { - return - } - if (externalRepo && externalRepoPath != null) { - if (checkExternalDirectory()) { - finish() - return - } - } - createRepository() - } - - private fun createPermGrantedAction(block: () -> Unit) = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - if (granted) { - block.invoke() - } - } - - companion object { - - fun newInstance(): RepoLocationFragment = RepoLocationFragment() - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/WelcomeFragment.kt b/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/WelcomeFragment.kt deleted file mode 100644 index 2ebab29f..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/onboarding/fragments/WelcomeFragment.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.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 com.zeapo.pwdstore.R -import com.zeapo.pwdstore.UserPreference -import com.zeapo.pwdstore.databinding.FragmentWelcomeBinding -import com.zeapo.pwdstore.utils.performTransactionWithBackStack -import com.zeapo.pwdstore.utils.viewBinding - -@Keep -@Suppress("unused") -class WelcomeFragment : Fragment(R.layout.fragment_welcome) { - - private val binding by viewBinding(FragmentWelcomeBinding::bind) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - binding.letsGo.setOnClickListener { parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) } - binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), UserPreference::class.java)) } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/ui/proxy/ProxySelectorActivity.kt b/app/src/main/java/com/zeapo/pwdstore/ui/proxy/ProxySelectorActivity.kt deleted file mode 100644 index 29c4d53a..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/ui/proxy/ProxySelectorActivity.kt +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.ui.proxy - -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Patterns -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.edit -import androidx.core.os.postDelayed -import androidx.core.widget.doOnTextChanged -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.databinding.ActivityProxySelectorBinding -import com.zeapo.pwdstore.git.config.GitSettings -import com.zeapo.pwdstore.utils.PreferenceKeys -import com.zeapo.pwdstore.utils.ProxyUtils -import com.zeapo.pwdstore.utils.getEncryptedProxyPrefs -import com.zeapo.pwdstore.utils.getString -import com.zeapo.pwdstore.utils.viewBinding - -private val IP_ADDRESS_REGEX = Patterns.IP_ADDRESS.toRegex() -private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex() - -class ProxySelectorActivity : AppCompatActivity() { - - private val binding by viewBinding(ActivityProxySelectorBinding::inflate) - private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) { applicationContext.getEncryptedProxyPrefs() } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - with(binding) { - proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST)) - proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME)) - proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let { - proxyPort.setText("$it") - } - proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD)) - save.setOnClickListener { saveSettings() } - proxyHost.doOnTextChanged { text, _, _, _ -> - if (text != null) { - proxyHost.error = if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) { - null - } else { - getString(R.string.invalid_proxy_url) - } - } - } - } - - } - - private fun saveSettings() { - proxyPrefs.edit { - binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let { - GitSettings.proxyHost = it - } - binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let { - GitSettings.proxyUsername = it - } - binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let { - GitSettings.proxyPort = it.toInt() - } - binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let { - GitSettings.proxyPassword = it - } - } - ProxyUtils.setDefaultProxy() - Handler(Looper.getMainLooper()).postDelayed(500) { finish() } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/AndroidExtensions.kt b/app/src/main/java/com/zeapo/pwdstore/utils/AndroidExtensions.kt deleted file mode 100644 index 408e9d5e..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/AndroidExtensions.kt +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.utils - -import android.app.KeyguardManager -import android.content.ClipboardManager -import android.content.Context -import android.content.SharedPreferences -import android.content.pm.PackageManager -import android.os.Build -import android.util.Base64 -import android.util.TypedValue -import android.view.View -import android.view.autofill.AutofillManager -import android.view.inputmethod.InputMethodManager -import androidx.annotation.IdRes -import androidx.annotation.RequiresApi -import androidx.appcompat.app.AlertDialog -import androidx.core.content.ContextCompat -import androidx.core.content.getSystemService -import androidx.fragment.app.FragmentActivity -import androidx.preference.PreferenceManager -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey -import com.github.ajalt.timberkt.d -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import com.google.android.material.snackbar.Snackbar -import com.zeapo.pwdstore.R -import com.zeapo.pwdstore.git.operation.GitOperation - -/** - * Extension function for [AlertDialog] that requests focus for the - * view whose id is [id]. Solution based on a StackOverflow - * answer: https://stackoverflow.com/a/13056259/297261 - */ -fun AlertDialog.requestInputFocusOnView(@IdRes id: Int) { - setOnShowListener { - findViewById(id)?.apply { - setOnFocusChangeListener { v, _ -> - v.post { - context.getSystemService() - ?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) - } - } - requestFocus() - } - } -} - -/** - * Get an instance of [AutofillManager]. Only - * available on Android Oreo and above - */ -val Context.autofillManager: AutofillManager? - @RequiresApi(Build.VERSION_CODES.O) - get() = getSystemService() - -/** - * Get an instance of [ClipboardManager] - */ -val Context.clipboard - get() = getSystemService() - -/** - * Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at - * each call site - */ -fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation") - -/** - * Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP - * proxy. - */ -fun Context.getEncryptedProxyPrefs() = getEncryptedPrefs("http_proxy") - -/** - * Get an instance of [EncryptedSharedPreferences] with the given [fileName] - */ -private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences { - val masterKeyAlias = MasterKey.Builder(applicationContext) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() - return EncryptedSharedPreferences.create( - applicationContext, - fileName, - masterKeyAlias, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) -} - -/** - * Get an instance of [KeyguardManager] - */ -val Context.keyguardManager: KeyguardManager - get() = getSystemService()!! - -/** - * Get the default [SharedPreferences] instance - */ -val Context.sharedPrefs: SharedPreferences - get() = PreferenceManager.getDefaultSharedPreferences(applicationContext) - - -/** - * Resolve [attr] from the [Context]'s theme - */ -fun Context.resolveAttribute(attr: Int): Int { - val typedValue = TypedValue() - this.theme.resolveAttribute(attr, typedValue, true) - return typedValue.data -} - -/** - * Commit changes to the store from a [FragmentActivity] using - * a custom implementation of [GitOperation] - */ -suspend fun FragmentActivity.commitChange( - message: String, -): Result { - if (!PasswordRepository.isGitRepo()) { - return Ok(Unit) - } - return object : GitOperation(this@commitChange) { - override val commands = arrayOf( - // Stage all files - git.add().addFilepattern("."), - // Populate the changed files count - git.status(), - // Commit everything! If anything changed, that is. - git.commit().setAll(true).setMessage(message), - ) - - override fun preExecute(): Boolean { - d { "Committing with message: '$message'" } - return true - } - }.execute() -} - -/** - * Check if [permission] has been granted to the app. - */ -fun FragmentActivity.isPermissionGranted(permission: String): Boolean { - return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED -} - -/** - * Show a [Snackbar] in a [FragmentActivity] and correctly - * anchor it to a [com.google.android.material.floatingactionbutton.FloatingActionButton] - * if one exists in the [view] - */ -fun FragmentActivity.snackbar( - view: View = findViewById(android.R.id.content), - message: String, - length: Int = Snackbar.LENGTH_SHORT, -): Snackbar { - val snackbar = Snackbar.make(view, message, length) - snackbar.anchorView = findViewById(R.id.fab) - snackbar.show() - return snackbar -} - -/** - * Simplifies the common `getString(key, null) ?: defaultValue` case slightly - */ -fun SharedPreferences.getString(key: String): String? = getString(key, null) - -/** - * Convert this [String] to its [Base64] representation - */ -fun String.base64(): String { - return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP) -} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt b/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt deleted file mode 100644 index 12ba84f1..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/BiometricAuthenticator.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.utils - -import android.app.KeyguardManager -import androidx.annotation.StringRes -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricManager.Authenticators -import androidx.biometric.BiometricPrompt -import androidx.core.content.ContextCompat -import androidx.core.content.getSystemService -import androidx.fragment.app.FragmentActivity -import com.github.ajalt.timberkt.Timber.tag -import com.github.ajalt.timberkt.d -import com.zeapo.pwdstore.R - -object BiometricAuthenticator { - - private const val TAG = "BiometricAuthenticator" - private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK - - sealed class Result { - data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() - data class Failure(val code: Int?, val message: CharSequence) : Result() - object HardwareUnavailableOrDisabled : Result() - object Cancelled : Result() - } - - fun canAuthenticate(activity: FragmentActivity): Boolean { - return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS - } - - fun authenticate( - activity: FragmentActivity, - @StringRes dialogTitleRes: Int = R.string.biometric_prompt_title, - callback: (Result) -> Unit - ) { - val authCallback = object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" } - callback(when (errorCode) { - BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED, - BiometricPrompt.ERROR_NEGATIVE_BUTTON -> { - Result.Cancelled - } - BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE, - BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> { - Result.HardwareUnavailableOrDisabled - } - else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString)) - }) - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error))) - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - callback(Result.Success(result.cryptoObject)) - } - } - val deviceHasKeyguard = activity.getSystemService()?.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/com/zeapo/pwdstore/utils/Extensions.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt deleted file mode 100644 index 7a439e94..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/Extensions.kt +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.utils - -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.runCatching -import java.io.File -import java.util.Date -import org.eclipse.jgit.lib.ObjectId -import org.eclipse.jgit.revwalk.RevCommit - -/** - * The default OpenPGP provider for the app - */ -const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain" - -/** - * Clears the given [flag] from the value of this [Int] - */ -fun Int.clearFlag(flag: Int): Int { - return this and flag.inv() -} - -/** - * Checks if this [Int] contains the given [flag] - */ -infix fun Int.hasFlag(flag: Int): Boolean { - return this and flag == flag -} - -/** - * Checks whether this [File] is a directory that contains [other]. - */ -fun File.contains(other: File): Boolean { - if (!isDirectory) - return false - if (!other.exists()) - return false - val relativePath = runCatching { - other.relativeTo(this) - }.getOrElse { - return false - } - // Direct containment is equivalent to the relative path being equal to the filename. - return relativePath.path == other.name -} - -/** - * Checks if this [File] is in the password repository directory as given - * by [PasswordRepository.getRepositoryDirectory] - */ -fun File.isInsideRepository(): Boolean { - return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath) -} - -/** - * Recursively lists the files in this [File], skipping any directories it encounters. - */ -fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList() - -/** - * Unique SHA-1 hash of this commit as hexadecimal string. - * - * @see RevCommit.getId - */ -val RevCommit.hash: String - get() = ObjectId.toString(id) - -/** - * Time this commit was made with second precision. - * - * @see RevCommit.commitTime - */ -val RevCommit.time: Date - get() { - val epochSeconds = commitTime.toLong() - val epochMilliseconds = epochSeconds * 1000 - return Date(epochMilliseconds) - } - -/** - * Splits this [String] into an [Array] of [String]s, split on the UNIX LF line ending - * and stripped of any empty lines. - */ -fun String.splitLines(): Array { - return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() -} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/FragmentExtensions.kt b/app/src/main/java/com/zeapo/pwdstore/utils/FragmentExtensions.kt deleted file mode 100644 index 251f5259..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/FragmentExtensions.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.zeapo.pwdstore.utils - -import androidx.annotation.IdRes -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.commit -import com.zeapo.pwdstore.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/com/zeapo/pwdstore/utils/FragmentViewBindingDelegate.kt b/app/src/main/java/com/zeapo/pwdstore/utils/FragmentViewBindingDelegate.kt deleted file mode 100644 index 55d654e0..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/FragmentViewBindingDelegate.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.utils - - -import android.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) = - lazy(LazyThreadSafetyMode.NONE) { - bindingInflater.invoke(layoutInflater) - } diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt b/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt deleted file mode 100644 index 994b4d82..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/Otp.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.utils - -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.runCatching -import java.nio.ByteBuffer -import java.util.Locale -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec -import kotlin.experimental.and -import org.apache.commons.codec.binary.Base32 - -object Otp { - - private val BASE_32 = Base32() - private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray() - - init { - check(STEAM_ALPHABET.size == 26) - } - - fun calculateCode(secret: String, counter: Long, algorithm: String, digits: String) = runCatching { - val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}" - val decodedSecret = BASE_32.decode(secret) - val secretKey = SecretKeySpec(decodedSecret, algo) - val digest = Mac.getInstance(algo).run { - init(secretKey) - doFinal(ByteBuffer.allocate(8).putLong(counter).array()) - } - // Least significant 4 bits are used as an offset into the digest. - val offset = (digest.last() and 0xf).toInt() - // Extract 32 bits at the offset and clear the most significant bit. - val code = digest.copyOfRange(offset, offset + 4) - code[0] = (0x7f and code[0].toInt()).toByte() - val codeInt = ByteBuffer.wrap(code).int - check(codeInt > 0) - if (digits == "s") { - // Steam - var remainingCodeInt = codeInt - buildString { - repeat(5) { - append(STEAM_ALPHABET[remainingCodeInt % 26]) - remainingCodeInt /= 26 - } - } - } else { - // Base 10, 6 to 10 digits - val numDigits = digits.toIntOrNull() - when { - numDigits == null -> { - return Err(IllegalArgumentException("Digits specifier has to be either 's' or numeric")) - } - numDigits < 6 -> { - return Err(IllegalArgumentException("TOTP codes have to be at least 6 digits long")) - } - numDigits > 10 -> { - return Err(IllegalArgumentException("TOTP codes can be at most 10 digits long")) - } - else -> { - // 2^31 = 2_147_483_648, so we can extract at most 10 digits with the first one - // always being 0, 1, or 2. Pad with leading zeroes. - val codeStringBase10 = codeInt.toString(10).padStart(10, '0') - check(codeStringBase10.length == 10) - codeStringBase10.takeLast(numDigits) - } - } - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordItem.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordItem.kt deleted file mode 100644 index ef31324b..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordItem.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.utils - -import com.zeapo.pwdstore.crypto.BasePgpActivity -import java.io.File - -data class PasswordItem( - val name: String, - val parent: PasswordItem? = null, - val type: Char, - val file: File, - val rootDir: File -) : Comparable { - - val fullPathToParent = file.absolutePath - .replace(rootDir.absolutePath, "") - .replace(file.name, "") - - val longName = BasePgpActivity.getLongName( - fullPathToParent, - rootDir.absolutePath, - toString()) - - override fun equals(other: Any?): Boolean { - return (other is PasswordItem) && (other.file == file) - } - - override fun compareTo(other: PasswordItem): Int { - return (type + name).compareTo(other.type + other.name, ignoreCase = true) - } - - override fun toString(): String { - return name.replace("\\.gpg$".toRegex(), "") - } - - override fun hashCode(): Int { - return 0 - } - - companion object { - - const val TYPE_CATEGORY = 'c' - const val TYPE_PASSWORD = 'p' - - @JvmStatic - fun newCategory( - name: String, - file: File, - parent: PasswordItem, - rootDir: File - ): PasswordItem { - return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir) - } - - @JvmStatic - fun newCategory( - name: String, - file: File, - rootDir: File - ): PasswordItem { - return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir) - } - - @JvmStatic - fun newPassword( - name: String, - file: File, - parent: PasswordItem, - rootDir: File - ): PasswordItem { - return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir) - } - - @JvmStatic - fun newPassword( - name: String, - file: File, - rootDir: File - ): PasswordItem { - return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir) - } - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt deleted file mode 100644 index 8b403a56..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordRepository.kt +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ -package com.zeapo.pwdstore.utils - -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.core.content.edit -import com.github.michaelbull.result.getOrElse -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.runCatching -import com.zeapo.pwdstore.Application -import java.io.File -import java.io.FileFilter -import java.nio.file.Files -import java.nio.file.LinkOption -import org.eclipse.jgit.api.Git -import org.eclipse.jgit.lib.Repository -import org.eclipse.jgit.storage.file.FileRepositoryBuilder -import org.eclipse.jgit.transport.RefSpec -import org.eclipse.jgit.transport.RemoteConfig -import org.eclipse.jgit.transport.URIish -import org.eclipse.jgit.util.FS -import org.eclipse.jgit.util.FS_POSIX_Java6 - -object PasswordRepository { - - @RequiresApi(Build.VERSION_CODES.O) - private class FS_POSIX_Java6_with_optional_symlinks : FS_POSIX_Java6() { - - override fun supportsSymlinks() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - - override fun isSymLink(file: File) = Files.isSymbolicLink(file.toPath()) - - override fun readSymLink(file: File) = Files.readSymbolicLink(file.toPath()).toString() - - override fun createSymLink(source: File, target: String) { - val sourcePath = source.toPath() - if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) - Files.delete(sourcePath) - Files.createSymbolicLink(sourcePath, File(target).toPath()) - } - } - - @RequiresApi(Build.VERSION_CODES.O) - private class Java7FSFactory : FS.FSFactory() { - - override fun detect(cygwinUsed: Boolean?): FS { - return FS_POSIX_Java6_with_optional_symlinks() - } - } - - private var repository: Repository? = null - private val settings by lazy(LazyThreadSafetyMode.NONE) { Application.instance.sharedPrefs } - private val filesDir - get() = Application.instance.filesDir - - /** - * Returns the git repository - * - * @param localDir needed only on the creation - * @return the git repository - */ - @JvmStatic - fun getRepository(localDir: File?): Repository? { - if (repository == null && localDir != null) { - val builder = FileRepositoryBuilder() - repository = runCatching { - builder.run { - gitDir = localDir - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - fs = Java7FSFactory().detect(null) - } - readEnvironment() - }.build() - }.getOrElse { e -> - e.printStackTrace() - null - } - } - return repository - } - - @JvmStatic - val isInitialized: Boolean - get() = repository != null - - @JvmStatic - fun isGitRepo(): Boolean { - if (repository != null) { - return repository!!.objectDatabase.exists() - } - return false - } - - @JvmStatic - @Throws(Exception::class) - fun createRepository(localDir: File) { - localDir.delete() - - Git.init().setDirectory(localDir).call() - getRepository(localDir) - } - - // TODO add multiple remotes support for pull/push - @JvmStatic - fun addRemote(name: String, url: String, replace: Boolean = false) { - val storedConfig = repository!!.config - val remotes = storedConfig.getSubsections("remote") - - if (!remotes.contains(name)) { - runCatching { - val uri = URIish(url) - val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*") - - val remoteConfig = RemoteConfig(storedConfig, name) - remoteConfig.addFetchRefSpec(refSpec) - remoteConfig.addPushRefSpec(refSpec) - remoteConfig.addURI(uri) - remoteConfig.addPushURI(uri) - - remoteConfig.update(storedConfig) - - storedConfig.save() - }.onFailure { e -> - e.printStackTrace() - } - } else if (replace) { - runCatching { - val uri = URIish(url) - - val remoteConfig = RemoteConfig(storedConfig, name) - // remove the first and eventually the only uri - if (remoteConfig.urIs.size > 0) { - remoteConfig.removeURI(remoteConfig.urIs[0]) - } - if (remoteConfig.pushURIs.size > 0) { - remoteConfig.removePushURI(remoteConfig.pushURIs[0]) - } - - remoteConfig.addURI(uri) - remoteConfig.addPushURI(uri) - - remoteConfig.update(storedConfig) - - storedConfig.save() - }.onFailure { e -> - e.printStackTrace() - } - } - } - - @JvmStatic - fun closeRepository() { - if (repository != null) repository!!.close() - repository = null - } - - @JvmStatic - fun getRepositoryDirectory(): File { - return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { - val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) - if (externalRepo != null) - File(externalRepo) - else - File(filesDir.toString(), "/store") - } else { - File(filesDir.toString(), "/store") - } - } - - @JvmStatic - fun initialize(): Repository? { - val dir = getRepositoryDirectory() - // uninitialize the repo if the dir does not exist or is absolutely empty - settings.edit { - if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) { - putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) - } else { - putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) - } - } - - // create the repository static variable in PasswordRepository - return getRepository(File(dir.absolutePath + "/.git")) - } - - /** - * Gets the .gpg files in a directory - * - * @param path the directory path - * @return the list of gpg files in that directory - */ - @JvmStatic - fun getFilesList(path: File?): ArrayList { - if (path == null || !path.exists()) return ArrayList() - - val directories = (path.listFiles(FileFilter { pathname -> pathname.isDirectory }) - ?: emptyArray()).toList() - val files = (path.listFiles(FileFilter { pathname -> pathname.extension == "gpg" }) - ?: emptyArray()).toList() - - val items = ArrayList() - items.addAll(directories) - items.addAll(files) - - return items - } - - /** - * Gets the passwords (PasswordItem) in a directory - * - * @param path the directory path - * @return a list of password items - */ - @JvmStatic - fun getPasswords(path: File, rootDir: File, sortOrder: PasswordSortOrder): ArrayList { - // 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/com/zeapo/pwdstore/utils/PasswordSortOrder.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PasswordSortOrder.kt deleted file mode 100644 index 0613c97d..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/PasswordSortOrder.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.utils - -import android.content.Context -import android.content.SharedPreferences -import com.zeapo.pwdstore.Application - -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/com/zeapo/pwdstore/utils/PreferenceKeys.kt b/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt deleted file mode 100644 index 70818568..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/PreferenceKeys.kt +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.utils - -object PreferenceKeys { - - const val APP_THEME = "app_theme" - const val APP_VERSION = "app_version" - const val AUTOFILL_ENABLE = "autofill_enable" - const val BIOMETRIC_AUTH = "biometric_auth" - const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x" - const val CLEAR_SAVED_PASS = "clear_saved_pass" - const val COPY_ON_DECRYPT = "copy_on_decrypt" - const val ENABLE_DEBUG_LOGGING = "enable_debug_logging" - const val EXPORT_PASSWORDS = "export_passwords" - const val FILTER_RECURSIVELY = "filter_recursively" - const val GENERAL_SHOW_TIME = "general_show_time" - const val GIT_CONFIG = "git_config" - const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email" - const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name" - const val GIT_EXTERNAL = "git_external" - const val GIT_EXTERNAL_REPO = "git_external_repo" - const val GIT_REMOTE_AUTH = "git_remote_auth" - const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type" - - @Deprecated("Use GIT_REMOTE_URL instead") - const val GIT_REMOTE_LOCATION = "git_remote_location" - const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing" - - @Deprecated("Use GIT_REMOTE_URL instead") - const val GIT_REMOTE_PORT = "git_remote_port" - - @Deprecated("Use GIT_REMOTE_URL instead") - const val GIT_REMOTE_PROTOCOL = "git_remote_protocol" - const val GIT_DELETE_REPO = "git_delete_repo" - - @Deprecated("Use GIT_REMOTE_URL instead") - const val GIT_REMOTE_SERVER = "git_remote_server" - const val GIT_REMOTE_URL = "git_remote_url" - - @Deprecated("Use GIT_REMOTE_URL instead") - const val GIT_REMOTE_USERNAME = "git_remote_username" - const val GIT_SERVER_INFO = "git_server_info" - const val GIT_BRANCH_NAME = "git_branch" - const val HTTPS_PASSWORD = "https_password" - const val LENGTH = "length" - const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes" - const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username" - const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure" - const val PREF_KEY_CUSTOM_DICT = "pref_key_custom_dict" - const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict" - const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" - const val PREF_SELECT_EXTERNAL = "pref_select_external" - const val REPOSITORY_INITIALIZED = "repository_initialized" - const val REPO_CHANGED = "repo_changed" - const val SEARCH_ON_START = "search_on_start" - const val SHOW_EXTRA_CONTENT = "show_extra_content" - - @Deprecated( - message = "Use SHOW_HIDDEN_CONTENTS instead", - replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS") - ) - const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders" - const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents" - const val SORT_ORDER = "sort_order" - const val SHOW_PASSWORD = "show_password" - const val SSH_KEY = "ssh_key" - const val SSH_KEYGEN = "ssh_keygen" - const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase" - const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid" - const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid" - const val SSH_SEE_KEY = "ssh_see_key" - - @Deprecated("To be used only in Migrations.kt") - const val USE_GENERATED_KEY = "use_generated_key" - - const val PROXY_SETTINGS = "proxy_settings" - const val PROXY_HOST = "proxy_host" - const val PROXY_PORT = "proxy_port" - const val PROXY_USERNAME = "proxy_username" - const val PROXY_PASSWORD = "proxy_password" -} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/ProxyUtils.kt b/app/src/main/java/com/zeapo/pwdstore/utils/ProxyUtils.kt deleted file mode 100644 index 4bb3dfcb..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/ProxyUtils.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.utils - -import com.zeapo.pwdstore.git.config.GitSettings -import java.io.IOException -import java.net.Authenticator -import java.net.InetSocketAddress -import java.net.PasswordAuthentication -import java.net.Proxy -import java.net.ProxySelector -import java.net.SocketAddress -import java.net.URI - -/** - * Utility class for [Proxy] handling. - */ -object ProxyUtils { - - private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser" - private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword" - - /** - * Set the default [Proxy] and [Authenticator] for the app based on user provided settings. - */ - fun setDefaultProxy() { - ProxySelector.setDefault(object : ProxySelector() { - override fun select(uri: URI?): MutableList { - val host = GitSettings.proxyHost - val port = GitSettings.proxyPort - return if (host == null || port == -1) { - mutableListOf(Proxy.NO_PROXY) - } else { - mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port))) - } - } - - override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { - if (uri == null || sa == null || ioe == null) { - throw IllegalArgumentException("Arguments can't be null.") - } - } - }) - val user = GitSettings.proxyUsername ?: "" - val password = GitSettings.proxyPassword ?: "" - if (user.isEmpty() || password.isEmpty()) { - System.clearProperty(HTTP_PROXY_USER_PROPERTY) - System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY) - } else { - System.setProperty(HTTP_PROXY_USER_PROPERTY, user) - System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password) - } - Authenticator.setDefault(object : Authenticator() { - override fun getPasswordAuthentication(): PasswordAuthentication? { - return if (requestorType == RequestorType.PROXY) { - PasswordAuthentication(user, password.toCharArray()) - } else { - null - } - } - }) - } -} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt b/app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt deleted file mode 100644 index 13a47543..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/TotpFinder.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.utils - -/** - * Defines a class that can extract relevant parts of a TOTP URL for use by the app. - */ -interface TotpFinder { - - /** - * Get the TOTP secret from the given extra content. - */ - fun findSecret(content: String): String? - - /** - * Get the number of digits required in the final OTP. - */ - fun findDigits(content: String): String - - /** - * Get the TOTP timeout period. - */ - fun findPeriod(content: String): Long - - /** - * Get the algorithm for the TOTP secret. - */ - fun findAlgorithm(content: String): String -} diff --git a/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt b/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt deleted file mode 100644 index 23101a13..00000000 --- a/app/src/main/java/com/zeapo/pwdstore/utils/UriTotpFinder.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package com.zeapo.pwdstore.utils - -import android.net.Uri - -/** - * [Uri] backed TOTP URL parser. - */ -class UriTotpFinder : TotpFinder { - - override fun findSecret(content: String): String? { - content.split("\n".toRegex()).forEach { line -> - if (line.startsWith(TOTP_FIELDS[0])) { - return Uri.parse(line).getQueryParameter("secret") - } - if (line.startsWith(TOTP_FIELDS[1], ignoreCase = true)) { - return line.split(": *".toRegex(), 2).toTypedArray()[1] - } - } - return null - } - - override fun findDigits(content: String): String { - content.split("\n".toRegex()).forEach { line -> - if (line.startsWith(TOTP_FIELDS[0]) && - Uri.parse(line).getQueryParameter("digits") != null) { - return Uri.parse(line).getQueryParameter("digits")!! - } - } - return "6" - } - - override fun findPeriod(content: String): Long { - content.split("\n".toRegex()).forEach { line -> - if (line.startsWith(TOTP_FIELDS[0]) && - Uri.parse(line).getQueryParameter("period") != null) { - val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull() - if (period != null && period > 0) - return period - } - } - return 30 - } - - override fun findAlgorithm(content: String): String { - content.split("\n".toRegex()).forEach { line -> - if (line.startsWith(TOTP_FIELDS[0]) && - Uri.parse(line).getQueryParameter("algorithm") != null) { - return Uri.parse(line).getQueryParameter("algorithm")!! - } - } - return "sha1" - } - - companion object { - - val TOTP_FIELDS = arrayOf( - "otpauth://totp", - "totp:" - ) - } -} diff --git a/app/src/main/java/dev/msfjarvis/aps/Application.kt b/app/src/main/java/dev/msfjarvis/aps/Application.kt new file mode 100644 index 00000000..ce7164c9 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/Application.kt @@ -0,0 +1,66 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps + +import android.content.SharedPreferences +import androidx.appcompat.app.AppCompatDelegate +import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY +import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM +import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_NO +import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES +import com.github.ajalt.timberkt.Timber.DebugTree +import com.github.ajalt.timberkt.Timber.plant +import dev.msfjarvis.aps.util.git.sshj.setUpBouncyCastleForSshj +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.proxy.ProxyUtils +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.settings.runMigrations + +@Suppress("Unused") +class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener { + + private val prefs by lazy { sharedPrefs } + + override fun onCreate() { + super.onCreate() + instance = this + if (BuildConfig.ENABLE_DEBUG_FEATURES || + prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) { + plant(DebugTree()) + } + prefs.registerOnSharedPreferenceChangeListener(this) + setNightMode() + setUpBouncyCastleForSshj() + runMigrations(applicationContext) + ProxyUtils.setDefaultProxy() + } + + override fun onTerminate() { + prefs.unregisterOnSharedPreferenceChangeListener(this) + super.onTerminate() + } + + override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) { + if (key == PreferenceKeys.APP_THEME) { + setNightMode() + } + } + + private fun setNightMode() { + AppCompatDelegate.setDefaultNightMode(when (prefs.getString(PreferenceKeys.APP_THEME) + ?: getString(R.string.app_theme_def)) { + "light" -> MODE_NIGHT_NO + "dark" -> MODE_NIGHT_YES + "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM + else -> MODE_NIGHT_AUTO_BATTERY + }) + } + + companion object { + + lateinit var instance: Application + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt new file mode 100644 index 00000000..3a6d9e2c --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt @@ -0,0 +1,139 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.data.password + +import com.github.michaelbull.result.get +import dev.msfjarvis.aps.util.totp.Otp +import dev.msfjarvis.aps.util.totp.TotpFinder +import dev.msfjarvis.aps.util.totp.UriTotpFinder +import java.io.ByteArrayOutputStream +import java.io.UnsupportedEncodingException +import java.util.Date + +/** + * A single entry in password store. [totpFinder] is an implementation of [TotpFinder] that let's us + * abstract out the Android-specific part and continue testing the class in the JVM. + */ +class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) { + + val password: String + val username: String? + val digits: String + val totpSecret: String? + val totpPeriod: Long + val totpAlgorithm: String + var extraContent: String + private set + + @Throws(UnsupportedEncodingException::class) + constructor(os: ByteArrayOutputStream) : this(os.toString("UTF-8"), UriTotpFinder()) + + init { + val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex())) + password = foundPassword + extraContent = passContent.joinToString("\n") + username = findUsername() + digits = findOtpDigits(content) + totpSecret = findTotpSecret(content) + totpPeriod = findTotpPeriod(content) + totpAlgorithm = findTotpAlgorithm(content) + } + + fun hasExtraContent(): Boolean { + return extraContent.isNotEmpty() + } + + fun hasTotp(): Boolean { + return totpSecret != null + } + + fun hasUsername(): Boolean { + return username != null + } + + fun calculateTotpCode(): String? { + if (totpSecret == null) + return null + return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get() + } + + val extraContentWithoutAuthData by lazy(LazyThreadSafetyMode.NONE) { + var foundUsername = false + extraContent.splitToSequence("\n").filter { line -> + return@filter when { + USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> { + foundUsername = true + false + } + line.startsWith("otpauth://", ignoreCase = true) || + line.startsWith("totp:", ignoreCase = true) -> { + false + } + else -> { + true + } + } + }.joinToString(separator = "\n") + } + + private fun findUsername(): String? { + extraContent.splitToSequence("\n").forEach { line -> + for (prefix in USERNAME_FIELDS) { + if (line.startsWith(prefix, ignoreCase = true)) + return line.substring(prefix.length).trimStart() + } + } + return null + } + + private fun findAndStripPassword(passContent: List): Pair> { + if (UriTotpFinder.TOTP_FIELDS.any { passContent[0].startsWith(it) }) return Pair("", passContent) + for (line in passContent) { + for (prefix in PASSWORD_FIELDS) { + if (line.startsWith(prefix, ignoreCase = true)) { + return Pair(line.substring(prefix.length).trimStart(), passContent.minus(line)) + } + } + } + return Pair(passContent[0], passContent.minus(passContent[0])) + } + + private fun findTotpSecret(decryptedContent: String): String? { + return totpFinder.findSecret(decryptedContent) + } + + private fun findOtpDigits(decryptedContent: String): String { + return totpFinder.findDigits(decryptedContent) + } + + private fun findTotpPeriod(decryptedContent: String): Long { + return totpFinder.findPeriod(decryptedContent) + } + + private fun findTotpAlgorithm(decryptedContent: String): String { + return totpFinder.findAlgorithm(decryptedContent) + } + + companion object { + + val USERNAME_FIELDS = arrayOf( + "login:", + "username:", + "user:", + "account:", + "email:", + "name:", + "handle:", + "id:", + "identity:" + ) + + val PASSWORD_FIELDS = arrayOf( + "password:", + "secret:", + "pass:", + ) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt new file mode 100644 index 00000000..75fc475b --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt @@ -0,0 +1,86 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.data.password + +import dev.msfjarvis.aps.ui.crypto.BasePgpActivity +import java.io.File + +data class PasswordItem( + val name: String, + val parent: PasswordItem? = null, + val type: Char, + val file: File, + val rootDir: File +) : Comparable { + + val fullPathToParent = file.absolutePath + .replace(rootDir.absolutePath, "") + .replace(file.name, "") + + val longName = BasePgpActivity.getLongName( + fullPathToParent, + rootDir.absolutePath, + toString()) + + override fun equals(other: Any?): Boolean { + return (other is PasswordItem) && (other.file == file) + } + + override fun compareTo(other: PasswordItem): Int { + return (type + name).compareTo(other.type + other.name, ignoreCase = true) + } + + override fun toString(): String { + return name.replace("\\.gpg$".toRegex(), "") + } + + override fun hashCode(): Int { + return 0 + } + + companion object { + + const val TYPE_CATEGORY = 'c' + const val TYPE_PASSWORD = 'p' + + @JvmStatic + fun newCategory( + name: String, + file: File, + parent: PasswordItem, + rootDir: File + ): PasswordItem { + return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir) + } + + @JvmStatic + fun newCategory( + name: String, + file: File, + rootDir: File + ): PasswordItem { + return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir) + } + + @JvmStatic + fun newPassword( + name: String, + file: File, + parent: PasswordItem, + rootDir: File + ): PasswordItem { + return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir) + } + + @JvmStatic + fun newPassword( + name: String, + file: File, + rootDir: File + ): PasswordItem { + return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt b/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt new file mode 100644 index 00000000..fecdba86 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt @@ -0,0 +1,243 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.data.repo + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.content.edit +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.Application +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.util.settings.PasswordSortOrder +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File +import java.io.FileFilter +import java.nio.file.Files +import java.nio.file.LinkOption +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.storage.file.FileRepositoryBuilder +import org.eclipse.jgit.transport.RefSpec +import org.eclipse.jgit.transport.RemoteConfig +import org.eclipse.jgit.transport.URIish +import org.eclipse.jgit.util.FS +import org.eclipse.jgit.util.FS_POSIX_Java6 + +object PasswordRepository { + + @RequiresApi(Build.VERSION_CODES.O) + private class FS_POSIX_Java6_with_optional_symlinks : FS_POSIX_Java6() { + + override fun supportsSymlinks() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + + override fun isSymLink(file: File) = Files.isSymbolicLink(file.toPath()) + + override fun readSymLink(file: File) = Files.readSymbolicLink(file.toPath()).toString() + + override fun createSymLink(source: File, target: String) { + val sourcePath = source.toPath() + if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) + Files.delete(sourcePath) + Files.createSymbolicLink(sourcePath, File(target).toPath()) + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private class Java7FSFactory : FS.FSFactory() { + + override fun detect(cygwinUsed: Boolean?): FS { + return FS_POSIX_Java6_with_optional_symlinks() + } + } + + private var repository: Repository? = null + private val settings by lazy(LazyThreadSafetyMode.NONE) { Application.instance.sharedPrefs } + private val filesDir + get() = Application.instance.filesDir + + /** + * Returns the git repository + * + * @param localDir needed only on the creation + * @return the git repository + */ + @JvmStatic + fun getRepository(localDir: File?): Repository? { + if (repository == null && localDir != null) { + val builder = FileRepositoryBuilder() + repository = runCatching { + builder.run { + gitDir = localDir + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + fs = Java7FSFactory().detect(null) + } + readEnvironment() + }.build() + }.getOrElse { e -> + e.printStackTrace() + null + } + } + return repository + } + + @JvmStatic + val isInitialized: Boolean + get() = repository != null + + @JvmStatic + fun isGitRepo(): Boolean { + if (repository != null) { + return repository!!.objectDatabase.exists() + } + return false + } + + @JvmStatic + @Throws(Exception::class) + fun createRepository(localDir: File) { + localDir.delete() + + Git.init().setDirectory(localDir).call() + getRepository(localDir) + } + + // TODO add multiple remotes support for pull/push + @JvmStatic + fun addRemote(name: String, url: String, replace: Boolean = false) { + val storedConfig = repository!!.config + val remotes = storedConfig.getSubsections("remote") + + if (!remotes.contains(name)) { + runCatching { + val uri = URIish(url) + val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*") + + val remoteConfig = RemoteConfig(storedConfig, name) + remoteConfig.addFetchRefSpec(refSpec) + remoteConfig.addPushRefSpec(refSpec) + remoteConfig.addURI(uri) + remoteConfig.addPushURI(uri) + + remoteConfig.update(storedConfig) + + storedConfig.save() + }.onFailure { e -> + e.printStackTrace() + } + } else if (replace) { + runCatching { + val uri = URIish(url) + + val remoteConfig = RemoteConfig(storedConfig, name) + // remove the first and eventually the only uri + if (remoteConfig.urIs.size > 0) { + remoteConfig.removeURI(remoteConfig.urIs[0]) + } + if (remoteConfig.pushURIs.size > 0) { + remoteConfig.removePushURI(remoteConfig.pushURIs[0]) + } + + remoteConfig.addURI(uri) + remoteConfig.addPushURI(uri) + + remoteConfig.update(storedConfig) + + storedConfig.save() + }.onFailure { e -> + e.printStackTrace() + } + } + } + + @JvmStatic + fun closeRepository() { + if (repository != null) repository!!.close() + repository = null + } + + @JvmStatic + fun getRepositoryDirectory(): File { + return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { + val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + if (externalRepo != null) + File(externalRepo) + else + File(filesDir.toString(), "/store") + } else { + File(filesDir.toString(), "/store") + } + } + + @JvmStatic + fun initialize(): Repository? { + val dir = getRepositoryDirectory() + // uninitialize the repo if the dir does not exist or is absolutely empty + settings.edit { + if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) { + putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) + } else { + putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) + } + } + + // create the repository static variable in PasswordRepository + return getRepository(File(dir.absolutePath + "/.git")) + } + + /** + * Gets the .gpg files in a directory + * + * @param path the directory path + * @return the list of gpg files in that directory + */ + @JvmStatic + fun getFilesList(path: File?): ArrayList { + if (path == null || !path.exists()) return ArrayList() + + val directories = (path.listFiles(FileFilter { pathname -> pathname.isDirectory }) + ?: emptyArray()).toList() + val files = (path.listFiles(FileFilter { pathname -> pathname.extension == "gpg" }) + ?: emptyArray()).toList() + + val items = ArrayList() + items.addAll(directories) + items.addAll(files) + + return items + } + + /** + * Gets the passwords (PasswordItem) in a directory + * + * @param path the directory path + * @return a list of password items + */ + @JvmStatic + fun getPasswords(path: File, rootDir: File, sortOrder: PasswordSortOrder): ArrayList { + // 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/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt new file mode 100644 index 00000000..92b0fe37 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt @@ -0,0 +1,83 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.adapters + +import android.text.SpannableString +import android.text.style.RelativeSizeSpan +import android.view.MotionEvent +import android.view.View +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.selection.Selection +import androidx.recyclerview.widget.RecyclerView +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter +import dev.msfjarvis.aps.util.viewmodel.stableId + +open class PasswordItemRecyclerAdapter : + SearchableRepositoryAdapter( + R.layout.password_row_layout, + ::PasswordItemViewHolder, + PasswordItemViewHolder::bind + ) { + + fun makeSelectable(recyclerView: RecyclerView) { + makeSelectable(recyclerView, ::PasswordItemDetailsLookup) + } + + override fun onItemClicked(listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit): PasswordItemRecyclerAdapter { + return super.onItemClicked(listener) as PasswordItemRecyclerAdapter + } + + override fun onSelectionChanged(listener: (selection: Selection) -> 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 + + fun bind(item: PasswordItem) { + val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") + val source = if (parentPath.isNotEmpty()) { + "$parentPath\n$item" + } else { + "$item" + } + val spannable = SpannableString(source) + spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0) + name.text = spannable + if (item.type == PasswordItem.TYPE_CATEGORY) { + folderIndicator.visibility = View.VISIBLE + val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size + ?: 0 + childCount.visibility = if (count > 0) View.VISIBLE else View.GONE + childCount.text = "$count" + } else { + childCount.visibility = View.GONE + folderIndicator.visibility = View.GONE + } + itemDetails = object : ItemDetailsLookup.ItemDetails() { + 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 new file mode 100644 index 00000000..5d6fb886 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt @@ -0,0 +1,245 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.autofill + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.autofill.AutofillManager +import android.widget.Toast +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import com.github.michaelbull.result.runCatching +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder +import dev.msfjarvis.aps.util.autofill.DirectoryStructure +import dev.msfjarvis.aps.data.password.PasswordEntry +import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 +import org.openintents.openpgp.OpenPgpError + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope { + + companion object { + + private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH" + private const val EXTRA_SEARCH_ACTION = + "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION" + + private var decryptFileRequestCode = 1 + + fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent { + return Intent(context, AutofillDecryptActivity::class.java).apply { + putExtras(forwardedExtras) + putExtra(EXTRA_SEARCH_ACTION, true) + putExtra(EXTRA_FILE_PATH, file.absolutePath) + } + } + + fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender { + val intent = Intent(context, AutofillDecryptActivity::class.java).apply { + putExtra(EXTRA_SEARCH_ACTION, false) + putExtra(EXTRA_FILE_PATH, file.absolutePath) + } + return PendingIntent.getActivity( + context, + decryptFileRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private val decryptInteractionRequiredAction = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (continueAfterUserInteraction != null) { + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + continueAfterUserInteraction?.resume(data) + } else { + continueAfterUserInteraction?.resumeWithException(Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")) + } + continueAfterUserInteraction = null + } + } + + private var continueAfterUserInteraction: Continuation? = null + private lateinit var directoryStructure: DirectoryStructure + + override val coroutineContext + get() = Dispatchers.IO + SupervisorJob() + + override fun onStart() { + super.onStart() + val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run { + e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" } + finish() + return + } + val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { + e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } + finish() + return + } + val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! + val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match + directoryStructure = AutofillPreferences.directoryStructure(this) + d { action.toString() } + launch { + val credentials = decryptCredential(File(filePath)) + if (credentials == null) { + setResult(RESULT_CANCELED) + } else { + val fillInDataset = + AutofillResponseBuilder.makeFillInDataset( + this@AutofillDecryptActivity, + credentials, + clientState, + action + ) + withContext(Dispatchers.Main) { + setResult(RESULT_OK, Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + }) + } + } + withContext(Dispatchers.Main) { + finish() + } + } + } + + override fun onDestroy() { + super.onDestroy() + coroutineContext.cancelChildren() + } + + private suspend fun executeOpenPgpApi( + data: Intent, + input: InputStream, + output: OutputStream + ): Intent? { + var openPgpServiceConnection: OpenPgpServiceConnection? = null + val openPgpService = suspendCoroutine { cont -> + openPgpServiceConnection = OpenPgpServiceConnection( + this, + OPENPGP_PROVIDER, + object : OpenPgpServiceConnection.OnBound { + override fun onBound(service: IOpenPgpService2) { + cont.resume(service) + } + + override fun onError(e: Exception) { + cont.resumeWithException(e) + } + }).also { it.bindToService() } + } + return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also { + openPgpServiceConnection?.unbindFromService() + } + } + + private suspend fun decryptCredential( + file: File, + resumeIntent: Intent? = null + ): Credentials? { + val command = resumeIntent ?: Intent().apply { + action = OpenPgpApi.ACTION_DECRYPT_VERIFY + } + runCatching { + file.inputStream() + }.onFailure { e -> + e(e) { "File to decrypt not found" } + return null + }.onSuccess { encryptedInput -> + val decryptedOutput = ByteArrayOutputStream() + runCatching { + executeOpenPgpApi(command, encryptedInput, decryptedOutput) + }.onFailure { e -> + e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" } + return null + }.onSuccess { result -> + return when (val resultCode = + result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + runCatching { + val entry = withContext(Dispatchers.IO) { + @Suppress("BlockingMethodInNonBlockingContext") + (PasswordEntry(decryptedOutput)) + } + AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure) + }.getOrElse { e -> + e(e) { "Failed to parse password entry" } + return null + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent: PendingIntent = + result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!! + runCatching { + val intentToResume = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + continueAfterUserInteraction = cont + decryptInteractionRequiredAction.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build()) + } + } + decryptCredential(file, intentToResume) + }.getOrElse { e -> + e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" } + return null + } + } + OpenPgpApi.RESULT_CODE_ERROR -> { + val error = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) + if (error != null) { + withContext(Dispatchers.Main) { + Toast.makeText( + applicationContext, + "Error from OpenKeyChain: ${error.message}", + Toast.LENGTH_LONG + ).show() + } + e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" } + } + null + } + else -> { + e { "Unrecognized OpenPgpApi result: $resultCode" } + null + } + } + } + } + return null + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt new file mode 100644 index 00000000..fe19a636 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt @@ -0,0 +1,220 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.autofill + +import android.annotation.TargetApi +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.autofill.AutofillManager +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.text.bold +import androidx.core.text.buildSpannedString +import androidx.core.text.underline +import androidx.core.widget.addTextChangedListener +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.FormOrigin +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.autofill.AutofillMatcher +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.DirectoryStructure +import dev.msfjarvis.aps.databinding.ActivityOreoAutofillFilterBinding +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.util.extensions.viewBinding +import dev.msfjarvis.aps.util.viewmodel.FilterMode +import dev.msfjarvis.aps.util.viewmodel.ListMode +import dev.msfjarvis.aps.util.viewmodel.SearchMode +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel + +@TargetApi(Build.VERSION_CODES.O) +class AutofillFilterView : AppCompatActivity() { + + companion object { + + private const val HEIGHT_PERCENTAGE = 0.9 + private const val WIDTH_PERCENTAGE = 0.75 + + private const val EXTRA_FORM_ORIGIN_WEB = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB" + private const val EXTRA_FORM_ORIGIN_APP = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP" + private var matchAndDecryptFileRequestCode = 1 + + fun makeMatchAndDecryptFileIntentSender( + context: Context, + formOrigin: FormOrigin + ): IntentSender { + val intent = Intent(context, AutofillFilterView::class.java).apply { + when (formOrigin) { + is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier) + is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier) + } + } + return PendingIntent.getActivity( + context, + matchAndDecryptFileRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private lateinit var formOrigin: FormOrigin + private lateinit var directoryStructure: DirectoryStructure + private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate) + + private val model: SearchableRepositoryViewModel by viewModels { + ViewModelProvider.AndroidViewModelFactory(application) + } + + private val decryptAction = registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + setResult(RESULT_OK, result.data) + } + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setFinishOnTouchOutside(true) + + val params = window.attributes + params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt() + params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt() + window.attributes = params + + if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) { + e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" } + finish() + return + } + formOrigin = when { + intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> { + FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!) + } + intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> { + FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!) + } + else -> { + e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" } + finish() + return + } + } + directoryStructure = AutofillPreferences.directoryStructure(this) + + supportActionBar?.hide() + bindUI() + updateSearch() + setResult(RESULT_CANCELED) + } + + private fun bindUI() { + with(binding) { + rvPassword.apply { + adapter = SearchableRepositoryAdapter( + R.layout.oreo_autofill_filter_row, + ::PasswordViewHolder + ) { item -> + val file = item.file.relativeTo(item.rootDir) + val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) + val identifier = directoryStructure.getIdentifierFor(file) + val accountPart = directoryStructure.getAccountPartFor(file) + check(identifier != null || accountPart != null) { "At least one of identifier and accountPart should always be non-null" } + title.text = if (identifier != null) { + buildSpannedString { + if (pathToIdentifier != null) + append("$pathToIdentifier/") + bold { underline { append(identifier) } } + } + } else { + accountPart + } + subtitle.apply { + if (identifier != null && accountPart != null) { + text = accountPart + visibility = View.VISIBLE + } else { + visibility = View.GONE + } + } + }.onItemClicked { _, item -> + decryptAndFill(item) + } + layoutManager = LinearLayoutManager(context) + } + search.apply { + val initialSearch = + formOrigin.getPrettyIdentifier(applicationContext, untrusted = false) + setText(initialSearch, TextView.BufferType.EDITABLE) + addTextChangedListener { updateSearch() } + } + origin.text = buildSpannedString { + append(getString(R.string.oreo_autofill_select_and_fill_into)) + append("\n") + bold { + append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) + } + } + strictDomainSearch.apply { + visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE + isChecked = formOrigin is FormOrigin.Web + setOnCheckedChangeListener { _, _ -> updateSearch() } + } + shouldMatch.text = getString( + R.string.oreo_autofill_match_with, + formOrigin.getPrettyIdentifier(applicationContext) + ) + model.searchResult.observe(this@AutofillFilterView) { result -> + val list = result.passwordItems + (rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { + rvPassword.scrollToPosition(0) + } + // Switch RecyclerView out for a "no results" message if the new list is empty and + // the message is not yet shown (and vice versa). + if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) || + (list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id) + ) { + rvPasswordSwitcher.showNext() + } + } + } + } + + private fun updateSearch() { + model.search( + binding.search.text.toString().trim(), + filterMode = if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy, + searchMode = SearchMode.RecursivelyInSubdirectories, + listMode = ListMode.FilesOnly + ) + } + + private fun decryptAndFill(item: PasswordItem) { + if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) + if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor( + applicationContext, + formOrigin, + item.file + ) + // intent?.extras? is checked to be non-null in onCreate + decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent( + item.file, + intent!!.extras!!, + this + )) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt new file mode 100644 index 00000000..a8edabbe --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt @@ -0,0 +1,114 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.autofill + +import android.annotation.TargetApi +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.service.autofill.FillResponse +import android.text.format.DateUtils +import android.view.View +import android.view.autofill.AutofillManager +import androidx.appcompat.app.AppCompatActivity +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.computeCertificatesHash +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.autofill.AutofillMatcher +import dev.msfjarvis.aps.util.autofill.AutofillPublisherChangedException +import dev.msfjarvis.aps.databinding.ActivityOreoAutofillPublisherChangedBinding +import dev.msfjarvis.aps.util.extensions.viewBinding + +@TargetApi(Build.VERSION_CODES.O) +class AutofillPublisherChangedActivity : AppCompatActivity() { + + companion object { + + private const val EXTRA_APP_PACKAGE = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE" + private const val EXTRA_FILL_RESPONSE_AFTER_RESET = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET" + private var publisherChangedRequestCode = 1 + + fun makePublisherChangedIntentSender( + context: Context, + publisherChangedException: AutofillPublisherChangedException, + fillResponseAfterReset: FillResponse?, + ): IntentSender { + val intent = Intent(context, AutofillPublisherChangedActivity::class.java).apply { + putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier) + putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset) + } + return PendingIntent.getActivity( + context, publisherChangedRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private lateinit var appPackage: String + private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setFinishOnTouchOutside(true) + + appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run { + e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" } + finish() + return + } + supportActionBar?.hide() + showPackageInfo() + with(binding) { + okButton.setOnClickListener { finish() } + advancedButton.setOnClickListener { + advancedButton.visibility = View.GONE + warningAppAdvancedInfo.visibility = View.VISIBLE + resetButton.visibility = View.VISIBLE + } + resetButton.setOnClickListener { + AutofillMatcher.clearMatchesFor(this@AutofillPublisherChangedActivity, FormOrigin.App(appPackage)) + val fillResponse = intent.getParcelableExtra(EXTRA_FILL_RESPONSE_AFTER_RESET) + setResult(RESULT_OK, Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) + }) + finish() + } + } + } + + private fun showPackageInfo() { + runCatching { + with(binding) { + val packageInfo = + packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA) + val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime) + warningAppInstallDate.text = + getString(R.string.oreo_autofill_warning_publisher_install_time, installTime) + val appInfo = + packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA) + warningAppName.text = "“${packageManager.getApplicationLabel(appInfo)}”" + + val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage) + warningAppAdvancedInfo.text = getString( + R.string.oreo_autofill_warning_publisher_advanced_info_template, + appPackage, + currentHash + ) + } + }.onFailure { e -> + e(e) { "Failed to retrieve package info for $appPackage" } + finish() + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt new file mode 100644 index 00000000..46234c4f --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt @@ -0,0 +1,149 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.autofill + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.view.autofill.AutofillManager +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FormOrigin +import dev.msfjarvis.aps.util.autofill.AutofillMatcher +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder +import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity +import dev.msfjarvis.aps.data.repo.PasswordRepository +import java.io.File + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillSaveActivity : AppCompatActivity() { + + companion object { + + private const val EXTRA_FOLDER_NAME = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME" + private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD" + private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME" + private const val EXTRA_SHOULD_MATCH_APP = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP" + private const val EXTRA_SHOULD_MATCH_WEB = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB" + private const val EXTRA_GENERATE_PASSWORD = + "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD" + + private var saveRequestCode = 1 + + fun makeSaveIntentSender( + context: Context, + credentials: Credentials?, + formOrigin: FormOrigin + ): IntentSender { + val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false) + // Prevent directory traversals + val sanitizedIdentifier = identifier.replace('\\', '_') + .replace('/', '_') + .trimStart('.') + .takeUnless { it.isBlank() } ?: formOrigin.identifier + val directoryStructure = AutofillPreferences.directoryStructure(context) + val folderName = directoryStructure.getSaveFolderName( + sanitizedIdentifier = sanitizedIdentifier, + username = credentials?.username + ) + val fileName = directoryStructure.getSaveFileName(username = credentials?.username, identifier = identifier) + val intent = Intent(context, AutofillSaveActivity::class.java).apply { + putExtras( + bundleOf( + EXTRA_FOLDER_NAME to folderName, + EXTRA_NAME to fileName, + EXTRA_PASSWORD to credentials?.password, + EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App }, + EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web }, + EXTRA_GENERATE_PASSWORD to (credentials == null) + ) + ) + } + return PendingIntent.getActivity( + context, + saveRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private val formOrigin by lazy(LazyThreadSafetyMode.NONE) { + val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP) + val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB) + if (shouldMatchApp != null && shouldMatchWeb == null) { + FormOrigin.App(shouldMatchApp) + } else if (shouldMatchApp == null && shouldMatchWeb != null) { + FormOrigin.Web(shouldMatchWeb) + } else { + null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val repo = PasswordRepository.getRepositoryDirectory() + val saveIntent = Intent(this, PasswordCreationActivity::class.java).apply { + putExtras( + bundleOf( + "REPO_PATH" to repo.absolutePath, + "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath, + PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME), + PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD), + PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) + ) + ) + } + registerForActivityResult(StartActivityForResult()) { result -> + val data = result.data + if (result.resultCode == RESULT_OK && data != null) { + val createdPath = data.getStringExtra("CREATED_FILE")!! + formOrigin?.let { + AutofillMatcher.addMatchFor(this, it, File(createdPath)) + } + val password = data.getStringExtra("PASSWORD") + val resultIntent = if (password != null) { + // Password was generated and should be filled into a form. + val username = data.getStringExtra("USERNAME") + val clientState = + intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { + e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } + finish() + return@registerForActivityResult + } + val credentials = Credentials(username, password, null) + val fillInDataset = AutofillResponseBuilder.makeFillInDataset( + this, + credentials, + clientState, + AutofillAction.Generate + ) + Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + } + } else { + // Password was extracted from a form, there is nothing to fill. + Intent() + } + setResult(RESULT_OK, resultIntent) + } else { + setResult(RESULT_CANCELED) + } + finish() + }.launch(saveIntent) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt new file mode 100644 index 00000000..e2fbaf76 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt @@ -0,0 +1,16 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.autofill + +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import dev.msfjarvis.aps.R + +class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) { + + val title: TextView = itemView.findViewById(R.id.title) + val subtitle: TextView = itemView.findViewById(R.id.subtitle) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt new file mode 100644 index 00000000..e75d6f0a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt @@ -0,0 +1,310 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.crypto + +import android.app.PendingIntent +import android.content.ClipData +import android.content.Intent +import android.content.IntentSender +import android.content.SharedPreferences +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.text.format.DateUtils +import android.view.WindowManager +import androidx.annotation.CallSuper +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import com.github.ajalt.timberkt.Timber.tag +import com.github.ajalt.timberkt.e +import com.github.ajalt.timberkt.i +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dev.msfjarvis.aps.util.services.ClipboardService +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.clipboard +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.extensions.snackbar +import java.io.File +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 +import org.openintents.openpgp.OpenPgpError + +@Suppress("Registered") +open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { + + /** + * Full path to the repository + */ + val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! } + + /** + * Full path to the password file being worked on + */ + val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! } + + /** + * Name of the password file + * + * Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org + */ + val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension } + + /** + * Get the timestamp for when this file was last modified. + */ + val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) { + getLastChangedString( + intent.getLongExtra( + "LAST_CHANGED_TIMESTAMP", + -1L + ) + ) + } + + /** + * [SharedPreferences] instance used by subclasses to persist settings + */ + val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs } + + /** + * Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain. + */ + private var serviceConnection: OpenPgpServiceConnection? = null + var api: OpenPgpApi? = null + + /** + * A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with + * in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package. + */ + private var previousListener: OpenPgpServiceConnection.OnBound? = null + + /** + * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots + * or recent apps screen. + */ + @CallSuper + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + tag(TAG) + } + + /** + * [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This + * is annotated with [CallSuper] because it's critical to unbind the service to ensure we're not + * leaking things. + */ + @CallSuper + override fun onDestroy() { + super.onDestroy() + serviceConnection?.unbindFromService() + previousListener = null + } + + /** + * [onResume] controls the flow for resumption of a PGP operation that was previously interrupted + * by the [OPENPGP_PROVIDER] package being missing. + */ + override fun onResume() { + super.onResume() + previousListener?.let { bindToOpenKeychain(it) } + } + + /** + * Sets up [api] once the service is bound. Downstream consumers must call super this to + * initialize [api] + */ + @CallSuper + override fun onBound(service: IOpenPgpService2) { + api = OpenPgpApi(this, service) + } + + /** + * Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle + * their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call super. + */ + override fun onError(e: Exception) { + e(e) { "Callers must handle their own exceptions" } + throw e + } + + /** + * Method for subclasses to initiate binding with [OpenPgpServiceConnection]. + */ + fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) { + val installed = runCatching { + packageManager.getPackageInfo(OPENPGP_PROVIDER, 0) + true + }.getOr(false) + if (!installed) { + previousListener = onBoundListener + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.openkeychain_not_installed_title)) + .setMessage(getString(R.string.openkeychain_not_installed_message)) + .setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ -> + runCatching { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER)) + setPackage("com.android.vending") + } + startActivity(intent) + } + } + .setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ -> + runCatching { + val intent = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER)) + } + startActivity(intent) + } + } + .setOnCancelListener { finish() } + .show() + return + } else { + previousListener = null + serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { + it.bindToService() + } + } + } + + /** + * Handle the case where OpenKeychain returns that it needs to interact with the user + * + * @param result The intent returned by OpenKeychain + */ + fun getUserInteractionRequestIntent(result: Intent): IntentSender { + i { "RESULT_CODE_USER_INTERACTION_REQUIRED" } + return result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!.intentSender + } + + /** + * Gets a relative string describing when this shape was last changed + * (e.g. "one hour ago") + */ + private fun getLastChangedString(timeStamp: Long): CharSequence { + if (timeStamp < 0) { + throw RuntimeException() + } + + return DateUtils.getRelativeTimeSpanString(this, timeStamp, true) + } + + /** + * Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses + * can use this when they want to default to sane error handling. + */ + fun handleError(result: Intent) { + val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) + if (error != null) { + when (error.errorId) { + OpenPgpError.NO_OR_WRONG_PASSPHRASE -> { + snackbar(message = getString(R.string.openpgp_error_wrong_passphrase)) + } + OpenPgpError.NO_USER_IDS -> { + snackbar(message = getString(R.string.openpgp_error_no_user_ids)) + } + else -> { + snackbar(message = getString(R.string.openpgp_error_unknown, error.message)) + e { "onError getErrorId: ${error.errorId}" } + e { "onError getMessage: ${error.message}" } + } + } + } + } + + /** + * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing + * [showSnackbar] as false. + */ + fun copyTextToClipboard( + text: String?, + showSnackbar: Boolean = true, + @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text + ) { + val clipboard = clipboard ?: return + val clip = ClipData.newPlainText("pgp_handler_result_pm", text) + clipboard.setPrimaryClip(clip) + if (showSnackbar) { + snackbar(message = resources.getString(snackbarTextRes)) + } + } + + /** + * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to + * hide the default [Snackbar] and starts off an instance of [ClipboardService] to provide a + * way of clearing the clipboard. + */ + fun copyPasswordToClipboard(password: String?) { + copyTextToClipboard(password, showSnackbar = false) + + val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45 + + if (clearAfter != 0) { + val service = Intent(this, ClipboardService::class.java).apply { + action = ClipboardService.ACTION_START + putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(service) + } else { + startService(service) + } + snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter)) + } else { + snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text)) + } + } + + companion object { + + private const val TAG = "APS/BasePgpActivity" + const val KEY_PWGEN_TYPE_CLASSIC = "classic" + const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" + + /** + * Gets the relative path to the repository + */ + fun getRelativePath(fullPath: String, repositoryPath: String): String = + fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + + /** + * Gets the Parent path, relative to the repository + */ + fun getParentPath(fullPath: String, repositoryPath: String): String { + val relativePath = getRelativePath(fullPath, repositoryPath) + val index = relativePath.lastIndexOf("/") + return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/") + } + + /** + * /path/to/store/social/facebook.gpg -> social/facebook + */ + @JvmStatic + fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { + var relativePath = getRelativePath(fullPath, repositoryPath) + return if (relativePath.isNotEmpty() && relativePath != "/") { + // remove preceding '/' + relativePath = relativePath.substring(1) + if (relativePath.endsWith('/')) { + relativePath + basename + } else { + "$relativePath/$basename" + } + } else { + basename + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt new file mode 100644 index 00000000..8f36cd25 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt @@ -0,0 +1,260 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.crypto + +import android.content.Intent +import android.graphics.Typeface +import android.os.Bundle +import android.text.method.PasswordTransformationMethod +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.DecryptLayoutBinding +import dev.msfjarvis.aps.data.password.PasswordEntry +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.ByteArrayOutputStream +import java.io.File +import kotlin.time.ExperimentalTime +import kotlin.time.seconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import org.openintents.openpgp.IOpenPgpService2 + +class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { + + private val binding by viewBinding(DecryptLayoutBinding::inflate) + + private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) } + private var passwordEntry: PasswordEntry? = null + + private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null) { + setResult(RESULT_CANCELED, null) + finish() + return@registerForActivityResult + } + + when (result.resultCode) { + RESULT_OK -> decryptAndVerify(result.data) + RESULT_CANCELED -> { + setResult(RESULT_CANCELED, result.data) + finish() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this) + title = name + with(binding) { + setContentView(root) + passwordCategory.text = relativeParentPath + passwordFile.text = name + passwordFile.setOnLongClickListener { + copyTextToClipboard(name) + true + } + passwordLastChanged.run { + runCatching { + text = resources.getString(R.string.last_changed, lastChangedString) + }.onFailure { + visibility = View.GONE + } + } + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.pgp_handler, menu) + passwordEntry?.let { entry -> + if (menu != null) { + menu.findItem(R.id.edit_password).isVisible = true + if (entry.password.isNotEmpty()) { + menu.findItem(R.id.share_password_as_plaintext).isVisible = true + menu.findItem(R.id.copy_password).isVisible = true + } + } + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> onBackPressed() + R.id.edit_password -> editPassword() + R.id.share_password_as_plaintext -> shareAsPlaintext() + R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password) + } + return super.onOptionsItemSelected(item) + } + + override fun onBound(service: IOpenPgpService2) { + super.onBound(service) + decryptAndVerify() + } + + override fun onError(e: Exception) { + e(e) + } + + /** + * Automatically finishes the activity 60 seconds after decryption succeeded to prevent + * information leaks from stale activities. + */ + @OptIn(ExperimentalTime::class) + private fun startAutoDismissTimer() { + lifecycleScope.launch { + delay(60.seconds) + finish() + } + } + + /** + * Edit the current password and hide all the fields populated by encrypted data so that when + * the result triggers they can be repopulated with new data. + */ + private fun editPassword() { + val intent = Intent(this, PasswordCreationActivity::class.java) + intent.putExtra("FILE_PATH", relativeParentPath) + intent.putExtra("REPO_PATH", repoPath) + intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name) + intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password) + intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent) + intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true) + startActivity(intent) + finish() + } + + private fun shareAsPlaintext() { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) + type = "text/plain" + } + // Always show a picker to give the user a chance to cancel + startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to))) + } + + @OptIn(ExperimentalTime::class) + private fun decryptAndVerify(receivedIntent: Intent? = null) { + if (api == null) { + bindToOpenKeychain(this) + return + } + val data = receivedIntent ?: Intent() + data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY + + val inputStream = File(fullPath).inputStream() + val outputStream = ByteArrayOutputStream() + + lifecycleScope.launch(Dispatchers.IO) { + api?.executeApiAsync(data, inputStream, outputStream) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + startAutoDismissTimer() + runCatching { + val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) + val showExtraContent = settings.getBoolean(PreferenceKeys.SHOW_EXTRA_CONTENT, true) + val monoTypeface = Typeface.createFromAsset(assets, "fonts/sourcecodepro.ttf") + val entry = PasswordEntry(outputStream) + + passwordEntry = entry + invalidateOptionsMenu() + + with(binding) { + if (entry.password.isEmpty()) { + passwordTextContainer.visibility = View.GONE + } else { + passwordTextContainer.visibility = View.VISIBLE + passwordText.typeface = monoTypeface + passwordText.setText(entry.password) + if (!showPassword) { + passwordText.transformationMethod = PasswordTransformationMethod.getInstance() + } + passwordTextContainer.setOnClickListener { copyPasswordToClipboard(entry.password) } + passwordText.setOnClickListener { copyPasswordToClipboard(entry.password) } + } + + if (entry.hasExtraContent()) { + if (entry.extraContentWithoutAuthData.isNotEmpty()) { + extraContentContainer.visibility = View.VISIBLE + extraContent.typeface = monoTypeface + extraContent.setText(entry.extraContentWithoutAuthData) + if (!showExtraContent) { + extraContent.transformationMethod = PasswordTransformationMethod.getInstance() + } + extraContentContainer.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } + extraContent.setOnClickListener { copyTextToClipboard(entry.extraContentWithoutAuthData) } + } + + if (entry.hasUsername()) { + usernameText.typeface = monoTypeface + usernameText.setText(entry.username) + usernameTextContainer.setEndIconOnClickListener { copyTextToClipboard(entry.username) } + usernameTextContainer.visibility = View.VISIBLE + } else { + usernameTextContainer.visibility = View.GONE + } + + if (entry.hasTotp()) { + otpTextContainer.visibility = View.VISIBLE + otpTextContainer.setEndIconOnClickListener { + copyTextToClipboard( + otpText.text.toString(), + snackbarTextRes = R.string.clipboard_otp_copied_text + ) + } + launch(Dispatchers.IO) { + // Calculate the actual remaining time for the first pass + // then return to the standard 30 second affair. + val remainingTime = entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod) + withContext(Dispatchers.Main) { + otpText.setText(entry.calculateTotpCode() + ?: "Error") + } + delay(remainingTime.seconds) + repeat(Int.MAX_VALUE) { + val code = entry.calculateTotpCode() ?: "Error" + withContext(Dispatchers.Main) { + otpText.setText(code) + } + delay(30.seconds) + } + } + } + } + } + + if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) { + copyPasswordToClipboard(entry.password) + } + }.onFailure { e -> + e(e) + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt new file mode 100644 index 00000000..f49537aa --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt @@ -0,0 +1,76 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.crypto + +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpUtils +import org.openintents.openpgp.IOpenPgpService2 + +class GetKeyIdsActivity : BasePgpActivity() { + + private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null || result.resultCode == RESULT_CANCELED) { + setResult(RESULT_CANCELED, result.data) + finish() + return@registerForActivityResult + } + getKeyIds(result.data!!) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this) + } + + override fun onBound(service: IOpenPgpService2) { + super.onBound(service) + getKeyIds() + } + + override fun onError(e: Exception) { + e(e) + } + + /** + * Get the Key ids from OpenKeychain + */ + private fun getKeyIds(data: Intent = Intent()) { + data.action = OpenPgpApi.ACTION_GET_KEY_IDS + lifecycleScope.launch(Dispatchers.IO) { + api?.executeApiAsync(data, null, null) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + runCatching { + val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { + OpenPgpUtils.convertKeyIdToHex(it) + } ?: emptyList() + val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray()) + setResult(RESULT_OK, keyResult) + finish() + }.onFailure { e -> + e(e) + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt new file mode 100644 index 00000000..403f6e2a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt @@ -0,0 +1,513 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.crypto + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.text.InputType +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.zxing.integration.android.IntentIntegrator +import com.google.zxing.integration.android.IntentIntegrator.QR_CODE +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.password.PasswordEntry +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.DirectoryStructure +import dev.msfjarvis.aps.ui.dialogs.PasswordGeneratorDialogFragment +import dev.msfjarvis.aps.ui.dialogs.XkPasswordGeneratorDialogFragment +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.databinding.PasswordCreationActivityBinding +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.base64 +import dev.msfjarvis.aps.util.extensions.commitChange +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.isInsideRepository +import dev.msfjarvis.aps.util.extensions.snackbar +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi +import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection +import me.msfjarvis.openpgpktx.util.OpenPgpUtils + +class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { + + private val binding by viewBinding(PasswordCreationActivityBinding::inflate) + + private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } + private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) } + private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } + private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) } + private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) } + private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } + private var oldCategory: String? = null + private var copy: Boolean = false + private var encryptionIntent: Intent = Intent() + + private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> + if (result.data == null) { + setResult(RESULT_CANCELED, null) + finish() + return@registerForActivityResult + } + + when (result.resultCode) { + RESULT_OK -> encrypt(result.data) + RESULT_CANCELED -> { + setResult(RESULT_CANCELED, result.data) + finish() + } + } + } + + private val otpImportAction = registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + binding.otpImportButton.isVisible = false + val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) + val contents = "${intentResult.contents}\n" + val currentExtras = binding.extraContent.text.toString() + if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') + binding.extraContent.append("\n$contents") + else + binding.extraContent.append(contents) + snackbar(message = getString(R.string.otp_import_success)) + } else { + snackbar(message = getString(R.string.otp_import_failure)) + } + } + + private val gpgKeySelectAction = registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> + lifecycleScope.launch { + val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") + withContext(Dispatchers.IO) { + gpgIdentifierFile.writeText(keyIds.joinToString("\n")) + } + commitChange(getString( + R.string.git_commit_gpg_id, + getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) + )).onSuccess { + encrypt(encryptionIntent) + } + } + } + } + } + + private fun File.findTillRoot(fileName: String, rootPath: File): File? { + val gpgFile = File(this, fileName) + if (gpgFile.exists()) return gpgFile + + if (this.absolutePath == rootPath.absolutePath) { + return null + } + + val parent = parentFile + return if (parent != null && parent.exists()) { + parent.findTillRoot(fileName, rootPath) + } else { + null + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + bindToOpenKeychain(this) + title = if (editing) + getString(R.string.edit_password) + else + getString(R.string.new_password_title) + with(binding) { + setContentView(root) + generatePassword.setOnClickListener { generatePassword() } + otpImportButton.setOnClickListener { + otpImportAction.launch(IntentIntegrator(this@PasswordCreationActivity) + .setOrientationLocked(false) + .setBeepEnabled(false) + .setDesiredBarcodeFormats(QR_CODE) + .createScanIntent() + ) + } + + directoryInputLayout.apply { + if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) { + isEnabled = true + } else { + setBackgroundColor(getColor(android.R.color.transparent)) + } + val path = getRelativePath(fullPath, repoPath) + // Keep empty path field visible if it is editable. + if (path.isEmpty() && !isEnabled) + visibility = View.GONE + else { + directory.setText(path) + oldCategory = path + } + } + if (suggestedName != null) { + filename.setText(suggestedName) + } else { + filename.requestFocus() + } + // Allow the user to quickly switch between storing the username as the filename or + // in the encrypted extras. This only makes sense if the directory structure is + // FileBased. + if (suggestedName == null && + AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == + DirectoryStructure.FileBased + ) { + encryptUsername.apply { + visibility = View.VISIBLE + setOnClickListener { + if (isChecked) { + // User wants to enable username encryption, so we add it to the + // encrypted extras as the first line. + val username = filename.text.toString() + val extras = "username:$username\n${extraContent.text}" + + filename.text?.clear() + extraContent.setText(extras) + } else { + // User wants to disable username encryption, so we extract the + // username from the encrypted extras and use it as the filename. + val entry = PasswordEntry("PASSWORD\n${extraContent.text}") + val username = entry.username + + // username should not be null here by the logic in + // updateViewState, but it could still happen due to + // input lag. + if (username != null) { + filename.setText(username) + extraContent.setText(entry.extraContentWithoutAuthData) + } + } + } + } + listOf(filename, extraContent).forEach { + it.doOnTextChanged { _, _, _, _ -> updateViewState() } + } + } + suggestedPass?.let { + password.setText(it) + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + suggestedExtra?.let { extraContent.setText(it) } + if (shouldGeneratePassword) { + generatePassword() + password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + } + } + updateViewState() + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.pgp_handler_new_password, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + setResult(RESULT_CANCELED) + finish() + } + R.id.save_password -> { + copy = false + encrypt() + } + R.id.save_and_copy_password -> { + copy = true + encrypt() + } + else -> return super.onOptionsItemSelected(item) + } + return true + } + + private fun generatePassword() { + when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) { + KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment() + .show(supportFragmentManager, "generator") + KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment() + .show(supportFragmentManager, "xkpwgenerator") + } + } + + private fun updateViewState() = with(binding) { + // Use PasswordEntry to parse extras for username + val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}") + encryptUsername.apply { + if (visibility != View.VISIBLE) + return@apply + val hasUsernameInFileName = filename.text.toString().isNotBlank() + val hasUsernameInExtras = entry.hasUsername() + isEnabled = hasUsernameInFileName xor hasUsernameInExtras + isChecked = hasUsernameInExtras + } + otpImportButton.isVisible = !entry.hasTotp() + } + + private sealed class GpgIdentifier { + data class KeyId(val id: Long) : GpgIdentifier() + data class UserId(val email: String) : GpgIdentifier() + } + + @OptIn(ExperimentalUnsignedTypes::class) + private fun parseGpgIdentifier(identifier: String): GpgIdentifier? { + if (identifier.isEmpty()) return null + // Match long key IDs: + // FF22334455667788 or 0xFF22334455667788 + val maybeLongKeyId = identifier.removePrefix("0x").takeIf { + it.matches("[a-fA-F0-9]{16}".toRegex()) + } + if (maybeLongKeyId != null) { + val keyId = maybeLongKeyId.toULong(16) + return GpgIdentifier.KeyId(keyId.toLong()) + } + + // Match fingerprints: + // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899 + val maybeFingerprint = identifier.removePrefix("0x").takeIf { + it.matches("[a-fA-F0-9]{40}".toRegex()) + } + if (maybeFingerprint != null) { + // Truncating to the long key ID is not a security issue since OpenKeychain only accepts + // non-ambiguous key IDs. + val keyId = maybeFingerprint.takeLast(16).toULong(16) + return GpgIdentifier.KeyId(keyId.toLong()) + } + + return OpenPgpUtils.splitUserId(identifier).email?.let { GpgIdentifier.UserId(it) } + } + + /** + * Encrypts the password and the extra content + */ + private fun encrypt(receivedIntent: Intent? = null) { + with(binding) { + val editName = filename.text.toString().trim() + val editPass = password.text.toString() + val editExtra = extraContent.text.toString() + + if (editName.isEmpty()) { + snackbar(message = resources.getString(R.string.file_toast_text)) + return@with + } else if (editName.contains('/')) { + snackbar(message = resources.getString(R.string.invalid_filename_text)) + return@with + } + + if (editPass.isEmpty() && editExtra.isEmpty()) { + snackbar(message = resources.getString(R.string.empty_toast_text)) + return@with + } + + if (copy) { + copyPasswordToClipboard(editPass) + } + + encryptionIntent = receivedIntent ?: Intent() + encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT + + // pass enters the key ID into `.gpg-id`. + val repoRoot = PasswordRepository.getRepositoryDirectory() + val gpgIdentifierFile = File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot) + if (gpgIdentifierFile == null) { + snackbar(message = resources.getString(R.string.failed_to_find_key_id)) + return@with + } + val gpgIdentifiers = gpgIdentifierFile.readLines() + .filter { it.isNotBlank() } + .map { line -> + parseGpgIdentifier(line) ?: run { + // The line being empty means this is most likely an empty `.gpg-id` file + // we created. Skip the validation so we can make the user add a real ID. + if (line.isEmpty()) return@run + if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { + snackbar(message = resources.getString(R.string.short_key_ids_unsupported)) + } else { + snackbar(message = resources.getString(R.string.invalid_gpg_id)) + } + return@with + } + } + if (gpgIdentifiers.isEmpty()) { + gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java)) + return@with + } + val keyIds = gpgIdentifiers.filterIsInstance().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, true) + + val content = "$editPass\n$editExtra" + val inputStream = ByteArrayInputStream(content.toByteArray()) + val outputStream = ByteArrayOutputStream() + + val path = when { + // If we allowed the user to edit the relative path, we have to consider it here instead + // of fullPath. + directoryInputLayout.isEnabled -> { + val editRelativePath = directory.text.toString().trim() + if (editRelativePath.isEmpty()) { + snackbar(message = resources.getString(R.string.path_toast_text)) + return + } + val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}") + if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) { + snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}") + return + } + + "${passwordDirectory.path}/$editName.gpg" + } + else -> "$fullPath/$editName.gpg" + } + + lifecycleScope.launch(Dispatchers.IO) { + api?.executeApiAsync(encryptionIntent, inputStream, outputStream) { result -> + when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { + OpenPgpApi.RESULT_CODE_SUCCESS -> { + runCatching { + val file = File(path) + // If we're not editing, this file should not already exist! + if (!editing && file.exists()) { + snackbar(message = getString(R.string.password_creation_duplicate_error)) + return@executeApiAsync + } + + if (!file.isInsideRepository()) { + snackbar(message = getString(R.string.message_error_destination_outside_repo)) + return@executeApiAsync + } + + file.outputStream().use { + it.write(outputStream.toByteArray()) + } + + //associate the new password name with the last name's timestamp in history + val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() + val timestamp = preference.getString(oldFilePathHash) + if (timestamp != null) { + preference.edit { + remove(oldFilePathHash) + putString(file.absolutePath.base64(), timestamp) + } + } + + val returnIntent = Intent() + returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path) + returnIntent.putExtra(RETURN_EXTRA_NAME, editName) + returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName)) + + if (shouldGeneratePassword) { + val directoryStructure = + AutofillPreferences.directoryStructure(applicationContext) + val entry = PasswordEntry(content) + returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) + val username = entry.username + ?: directoryStructure.getUsernameFor(file) + returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) + } + + if (directoryInputLayout.isVisible && directoryInputLayout.isEnabled && oldFileName != null) { + val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg") + if (oldFile.path != file.path && !oldFile.delete()) { + setResult(RESULT_CANCELED) + MaterialAlertDialogBuilder(this@PasswordCreationActivity) + .setTitle(R.string.password_creation_file_fail_title) + .setMessage(getString(R.string.password_creation_file_delete_fail_message, oldFileName)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> + finish() + } + .show() + return@executeApiAsync + } + } + + val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text + lifecycleScope.launch { + commitChange(resources.getString( + commitMessageRes, + getLongName(fullPath, repoPath, editName) + )).onSuccess { + setResult(RESULT_OK, returnIntent) + finish() + } + } + + }.onFailure { e -> + if (e is IOException) { + e(e) { "Failed to write password file" } + setResult(RESULT_CANCELED) + MaterialAlertDialogBuilder(this@PasswordCreationActivity) + .setTitle(getString(R.string.password_creation_file_fail_title)) + .setMessage(getString(R.string.password_creation_file_write_fail_message)) + .setCancelable(false) + .setPositiveButton(android.R.string.ok) { _, _ -> + finish() + } + .show() + } else { + e(e) + } + } + } + OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val sender = getUserInteractionRequestIntent(result) + userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) + } + OpenPgpApi.RESULT_CODE_ERROR -> handleError(result) + } + } + } + } + } + + companion object { + + private const val KEY_PWGEN_TYPE_CLASSIC = "classic" + private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" + const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" + const val RETURN_EXTRA_NAME = "NAME" + const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" + const val RETURN_EXTRA_USERNAME = "USERNAME" + const val RETURN_EXTRA_PASSWORD = "PASSWORD" + const val EXTRA_FILE_NAME = "FILENAME" + const val EXTRA_PASSWORD = "PASSWORD" + const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" + const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" + const val EXTRA_EDITING = "EDITING" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt new file mode 100644 index 00000000..c2577443 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt @@ -0,0 +1,161 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.dialogs + +import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.BasicBottomSheetBinding +import dev.msfjarvis.aps.util.extensions.resolveAttribute +import dev.msfjarvis.aps.util.extensions.viewBinding + +/** + * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like + * API through [Builder] to create a similar UI, just at the bottom of the screen. + */ +class BasicBottomSheet private constructor( + val title: String?, + val message: String, + val positiveButtonLabel: String?, + val negativeButtonLabel: String?, + val positiveButtonClickListener: View.OnClickListener?, + val negativeButtonClickListener: View.OnClickListener?, +) : BottomSheetDialogFragment() { + + private val binding by viewBinding(BasicBottomSheetBinding::bind) + + private var behavior: BottomSheetBehavior? = null + private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { + override fun onSlide(bottomSheet: View, slideOffset: Float) { + } + + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_COLLAPSED) { + dismiss() + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + if (savedInstanceState != null) dismiss() + return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + view.viewTreeObserver.removeOnGlobalLayoutListener(this) + val dialog = dialog as BottomSheetDialog? ?: return + behavior = dialog.behavior + behavior?.apply { + state = BottomSheetBehavior.STATE_EXPANDED + peekHeight = 0 + addBottomSheetCallback(bottomSheetCallback) + } + if (!title.isNullOrEmpty()) { + binding.bottomSheetTitle.isVisible = true + binding.bottomSheetTitle.text = title + } + binding.bottomSheetMessage.text = message + if (positiveButtonClickListener != null) { + positiveButtonLabel?.let { buttonLbl -> + binding.bottomSheetOkButton.text = buttonLbl + } + binding.bottomSheetOkButton.isVisible = true + binding.bottomSheetOkButton.setOnClickListener { + positiveButtonClickListener.onClick(it) + dismiss() + } + } + if (negativeButtonClickListener != null) { + binding.bottomSheetCancelButton.isVisible = true + negativeButtonLabel?.let { buttonLbl -> + binding.bottomSheetCancelButton.text = buttonLbl + } + binding.bottomSheetCancelButton.setOnClickListener { + negativeButtonClickListener.onClick(it) + dismiss() + } + } + } + }) + val gradientDrawable = GradientDrawable().apply { + setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) + } + view.background = gradientDrawable + } + + override fun dismiss() { + super.dismiss() + behavior?.removeBottomSheetCallback(bottomSheetCallback) + } + + class Builder(val context: Context) { + + private var title: String? = null + private var message: String? = null + private var positiveButtonLabel: String? = null + private var negativeButtonLabel: String? = null + private var positiveButtonClickListener: View.OnClickListener? = null + private var negativeButtonClickListener: View.OnClickListener? = null + + fun setTitleRes(@StringRes titleRes: Int): Builder { + this.title = context.resources.getString(titleRes) + return this + } + + fun setTitle(title: String): Builder { + this.title = title + return this + } + + fun setMessageRes(@StringRes messageRes: Int): Builder { + this.message = context.resources.getString(messageRes) + return this + } + + fun setMessage(message: String): Builder { + this.message = message + return this + } + + fun setPositiveButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder { + this.positiveButtonClickListener = listener + this.positiveButtonLabel = buttonLabel + return this + } + + fun setNegativeButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder { + this.negativeButtonClickListener = listener + this.negativeButtonLabel = buttonLabel + return this + } + + fun build(): BasicBottomSheet { + require(message != null) { "Message needs to be set" } + return BasicBottomSheet( + title, + message!!, + positiveButtonLabel, + negativeButtonLabel, + positiveButtonClickListener, + negativeButtonClickListener + ) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt new file mode 100644 index 00000000..7f50a619 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt @@ -0,0 +1,106 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.dialogs + +import android.app.Dialog +import android.content.Intent +import android.os.Bundle +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import dev.msfjarvis.aps.ui.passwords.PasswordStore +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.crypto.BasePgpActivity +import dev.msfjarvis.aps.ui.crypto.GetKeyIdsActivity +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.extensions.commitChange +import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView +import java.io.File +import kotlinx.coroutines.launch +import me.msfjarvis.openpgpktx.util.OpenPgpApi + +class FolderCreationDialogFragment : DialogFragment() { + + private lateinit var newFolder: File + + private val keySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> + val gpgIdentifierFile = File(newFolder, ".gpg-id") + gpgIdentifierFile.writeText(keyIds.joinToString("\n")) + val repo = PasswordRepository.getRepository(null) + if (repo != null) { + lifecycleScope.launch { + val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath + requireActivity().commitChange( + getString( + R.string.git_commit_gpg_id, + BasePgpActivity.getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) + ), + ) + dismiss() + } + } + } + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext()) + alertDialogBuilder.setTitle(R.string.title_create_folder) + alertDialogBuilder.setView(R.layout.folder_dialog_fragment) + alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null) + alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> + dismiss() + } + val dialog = alertDialogBuilder.create() + dialog.requestInputFocusOnView(R.id.folder_name_text) + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!) + } + } + return dialog + } + + private fun createDirectory(currentDir: String) { + val dialog = requireDialog() + val folderNameView = dialog.findViewById(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 new file mode 100644 index 00000000..4889f1e5 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt @@ -0,0 +1,77 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.dialogs + +import android.graphics.drawable.GradientDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.widget.FrameLayout +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_FOLDER +import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_KEY +import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ACTION_PASSWORD +import dev.msfjarvis.aps.ui.passwords.PasswordFragment.Companion.ITEM_CREATION_REQUEST_KEY +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.extensions.resolveAttribute + +class ItemCreationBottomSheet : BottomSheetDialogFragment() { + + private var behavior: BottomSheetBehavior? = 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() + } + } + }) + val gradientDrawable = GradientDrawable().apply { + setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) + } + view.background = gradientDrawable + } + + override fun dismiss() { + super.dismiss() + behavior?.removeBottomSheetCallback(bottomSheetCallback) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt new file mode 100644 index 00000000..0676ef1c --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt @@ -0,0 +1,101 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.dialogs + +import android.annotation.SuppressLint +import android.app.AlertDialog +import android.app.Dialog +import android.content.Context +import android.graphics.Typeface +import android.os.Bundle +import android.widget.CheckBox +import android.widget.EditText +import android.widget.Toast +import androidx.annotation.IdRes +import androidx.appcompat.widget.AppCompatEditText +import androidx.appcompat.widget.AppCompatTextView +import androidx.fragment.app.DialogFragment +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.pwgen.PasswordGenerator +import dev.msfjarvis.aps.util.pwgen.PasswordGenerator.generate +import dev.msfjarvis.aps.util.pwgen.PasswordGenerator.setPrefs +import dev.msfjarvis.aps.util.pwgen.PasswordOption +import dev.msfjarvis.aps.util.settings.PreferenceKeys + +class PasswordGeneratorDialogFragment : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val callingActivity = requireActivity() + val inflater = callingActivity.layoutInflater + + @SuppressLint("InflateParams") + val view = inflater.inflate(R.layout.fragment_pwgen, null) + val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf") + val prefs = requireActivity().applicationContext + .getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) + + view.findViewById(R.id.numerals)?.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false) + view.findViewById(R.id.symbols)?.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false) + view.findViewById(R.id.uppercase)?.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false) + view.findViewById(R.id.lowercase)?.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false) + view.findViewById(R.id.ambiguous)?.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false) + view.findViewById(R.id.pronounceable)?.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true) + + val textView: AppCompatEditText = view.findViewById(R.id.lengthNumber) + textView.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString()) + val passwordText: AppCompatTextView = view.findViewById(R.id.passwordText) + passwordText.typeface = monoTypeface + return MaterialAlertDialogBuilder(requireContext()).run { + setTitle(R.string.pwgen_title) + setView(view) + setPositiveButton(R.string.dialog_ok) { _, _ -> + val edit = callingActivity.findViewById(R.id.password) + edit.setText(passwordText.text) + } + setNeutralButton(R.string.dialog_cancel) { _, _ -> } + setNegativeButton(R.string.pwgen_generate, null) + create() + }.apply { + setOnShowListener { + generate(passwordText) + getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { + generate(passwordText) + } + } + } + } + + private fun generate(passwordField: AppCompatTextView) { + setPreferences() + passwordField.text = runCatching { + generate(requireContext().applicationContext) + }.getOrElse { e -> + Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show() + "" + } + } + + private fun isChecked(@IdRes id: Int): Boolean { + return requireDialog().findViewById(id).isChecked + } + + private fun setPreferences() { + val preferences = listOfNotNull( + PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, + PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) }, + PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, + PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) }, + PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) }, + PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) } + ) + val lengthText = requireDialog().findViewById(R.id.lengthNumber).text.toString() + val length = lengthText.toIntOrNull()?.takeIf { it >= 0 } + ?: PasswordGenerator.DEFAULT_LENGTH + setPrefs(requireActivity().applicationContext, preferences, length) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt new file mode 100644 index 00000000..995c7e2f --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt @@ -0,0 +1,131 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.dialogs + +import android.app.Dialog +import android.content.Context +import android.content.SharedPreferences +import android.graphics.Typeface +import android.os.Bundle +import android.widget.EditText +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.edit +import androidx.fragment.app.DialogFragment +import com.github.ajalt.timberkt.Timber.tag +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.FragmentXkpwgenBinding +import dev.msfjarvis.aps.util.pwgenxkpwd.CapsType +import dev.msfjarvis.aps.util.pwgenxkpwd.PasswordBuilder +import dev.msfjarvis.aps.util.extensions.getString + +/** A placeholder fragment containing a simple view. */ +class XkPasswordGeneratorDialogFragment : DialogFragment() { + + private lateinit var prefs: SharedPreferences + private lateinit var binding: FragmentXkpwgenBinding + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val builder = MaterialAlertDialogBuilder(requireContext()) + val callingActivity = requireActivity() + val inflater = callingActivity.layoutInflater + binding = FragmentXkpwgenBinding.inflate(inflater) + + val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf") + + builder.setView(binding.root) + + prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) + + val previousStoredCapStyle: String = runCatching { + prefs.getString(PREF_KEY_CAPITALS_STYLE)!! + }.getOr(DEFAULT_CAPS_STYLE) + + val lastCapitalsStyleIndex: Int = runCatching { + CapsType.valueOf(previousStoredCapStyle).ordinal + }.getOr(DEFAULT_CAPS_INDEX) + binding.xkCapType.setSelection(lastCapitalsStyleIndex) + binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS)) + + binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR)) + binding.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK)) + + binding.xkPasswordText.typeface = monoTypeface + + builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> + setPreferences() + val edit = callingActivity.findViewById(R.id.password) + edit.setText(binding.xkPasswordText.text) + } + + // flip neutral and negative buttons + builder.setNeutralButton(resources.getString(R.string.dialog_cancel)) { _, _ -> } + builder.setNegativeButton(resources.getString(R.string.pwgen_generate), null) + + val dialog = builder.setTitle(this.resources.getString(R.string.xkpwgen_title)).create() + + dialog.setOnShowListener { + setPreferences() + makeAndSetPassword(binding.xkPasswordText) + + dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { + setPreferences() + makeAndSetPassword(binding.xkPasswordText) + } + } + return dialog + } + + private fun makeAndSetPassword(passwordText: AppCompatTextView) { + PasswordBuilder(requireContext()) + .setNumberOfWords(Integer.valueOf(binding.xkNumWords.text.toString())) + .setMinimumWordLength(DEFAULT_MIN_WORD_LENGTH) + .setMaximumWordLength(DEFAULT_MAX_WORD_LENGTH) + .setSeparator(binding.xkSeparator.text.toString()) + .appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT }) + .appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL }) + .setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString())).create() + .fold( + success = { passwordText.text = it }, + failure = { e -> + Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show() + tag("xkpw").e(e, "failure generating xkpasswd") + passwordText.text = FALLBACK_ERROR_PASS + }, + ) + } + + private fun setPreferences() { + prefs.edit { + putString(PREF_KEY_CAPITALS_STYLE, binding.xkCapType.selectedItem.toString()) + putString(PREF_KEY_NUM_WORDS, binding.xkNumWords.text.toString()) + putString(PREF_KEY_SEPARATOR, binding.xkSeparator.text.toString()) + putString(PREF_KEY_EXTRA_SYMBOLS_MASK, binding.xkNumberSymbolMask.text.toString()) + } + } + + companion object { + + const val PREF_KEY_CAPITALS_STYLE = "pref_key_capitals_style" + const val PREF_KEY_NUM_WORDS = "pref_key_num_words" + const val PREF_KEY_SEPARATOR = "pref_key_separator" + const val PREF_KEY_EXTRA_SYMBOLS_MASK = "pref_key_xkpwgen_extra_symbols_mask" + val DEFAULT_CAPS_STYLE = CapsType.Sentence.name + val DEFAULT_CAPS_INDEX = CapsType.Sentence.ordinal + const val DEFAULT_NUMBER_OF_WORDS = "3" + const val DEFAULT_WORD_SEPARATOR = "." + const val DEFAULT_EXTRA_SYMBOLS_MASK = "ds" + const val DEFAULT_MIN_WORD_LENGTH = 3 + const val DEFAULT_MAX_WORD_LENGTH = 9 + const val FALLBACK_ERROR_PASS = "42" + const val EXTRA_CHAR_PLACEHOLDER_DIGIT = 'd' + const val EXTRA_CHAR_PLACEHOLDER_SYMBOL = 's' + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt new file mode 100644 index 00000000..f2d655bb --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt @@ -0,0 +1,63 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.folderselect + +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.ui.passwords.PASSWORD_FRAGMENT_TAG +import dev.msfjarvis.aps.ui.passwords.PasswordStore + + +class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) { + + private lateinit var passwordList: SelectFolderFragment + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + passwordList = SelectFolderFragment() + val args = Bundle() + args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) + + passwordList.arguments = args + + supportActionBar?.show() + + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + + supportFragmentManager.commit { + replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.pgp_handler_select_folder, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { + setResult(RESULT_CANCELED) + finish() + return true + } + R.id.crypto_select -> selectFolder() + } + return super.onOptionsItemSelected(item) + } + + private fun selectFolder() { + intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath) + setResult(RESULT_OK, intent) + finish() + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt new file mode 100644 index 00000000..597b968c --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt @@ -0,0 +1,81 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.folderselect + +import android.content.Context +import android.os.Bundle +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.util.viewmodel.ListMode +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel +import dev.msfjarvis.aps.databinding.PasswordRecyclerViewBinding +import dev.msfjarvis.aps.ui.adapters.PasswordItemRecyclerAdapter +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.ui.passwords.PasswordStore +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.File +import me.zhanghai.android.fastscroll.FastScrollerBuilder + +class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { + + private val binding by viewBinding(PasswordRecyclerViewBinding::bind) + private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter + private lateinit var listener: OnFragmentInteractionListener + + private val model: SearchableRepositoryViewModel by activityViewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.fab.hide() + recyclerAdapter = PasswordItemRecyclerAdapter() + .onItemClicked { _, item -> + listener.onFragmentInteraction(item) + } + binding.passRecycler.apply { + layoutManager = LinearLayoutManager(requireContext()) + itemAnimator = null + adapter = recyclerAdapter + } + + FastScrollerBuilder(binding.passRecycler).build() + registerForContextMenu(binding.passRecycler) + + val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) + model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false) + model.searchResult.observe(viewLifecycleOwner) { result -> + recyclerAdapter.submitList(result.passwordItems) + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + runCatching { + listener = object : OnFragmentInteractionListener { + override fun onFragmentInteraction(item: PasswordItem) { + if (item.type == PasswordItem.TYPE_CATEGORY) { + model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly) + (requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + } + } + }.onFailure { + throw ClassCastException("$context must implement OnFragmentInteractionListener") + } + } + + val currentDir: File + get() = model.currentDir.value!! + + interface OnFragmentInteractionListener { + + fun onFragmentInteraction(item: PasswordItem) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt new file mode 100644 index 00000000..89455c90 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt @@ -0,0 +1,146 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.git.base + +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import com.github.ajalt.timberkt.d +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.mapError +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.util.git.operation.BreakOutOfDetached +import dev.msfjarvis.aps.util.git.operation.CloneOperation +import dev.msfjarvis.aps.util.git.operation.PullOperation +import dev.msfjarvis.aps.util.git.operation.PushOperation +import dev.msfjarvis.aps.util.git.operation.ResetToRemoteOperation +import dev.msfjarvis.aps.util.git.operation.SyncOperation +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.git.ErrorMessages +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.userauth.UserAuthException + +/** + * Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related + * tasks and makes sense to be held here. + */ +abstract class BaseGitActivity : ContinuationContainerActivity() { + + /** + * Enum of possible Git operations than can be run through [launchGitOperation]. + */ + enum class GitOp { + + BREAK_OUT_OF_DETACHED, + CLONE, + PULL, + PUSH, + RESET, + SYNC, + } + + /** + * Attempt to launch the requested Git operation. + * @param operation The type of git operation to launch + */ + suspend fun launchGitOperation(operation: GitOp): Result { + if (GitSettings.url == null) { + return Err(IllegalStateException("Git url is not set!")) + } + if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) { + // If the server does not support multiple SSH channels per connection, we cannot run + // a sync operation without reconnecting and thus break sync into its two parts. + return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) } + } + val op = when (operation) { + GitOp.CLONE -> CloneOperation(this, GitSettings.url!!) + GitOp.PULL -> PullOperation(this) + GitOp.PUSH -> PushOperation(this) + GitOp.SYNC -> SyncOperation(this) + GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this) + GitOp.RESET -> ResetToRemoteOperation(this) + } + return op.executeAfterAuthentication(GitSettings.authMode).mapError { throwable -> + val err = rootCauseException(throwable) + if (err.message?.contains("cannot open additional channels") == true) { + GitSettings.useMultiplexing = false + SSHException(DisconnectReason.TOO_MANY_CONNECTIONS, "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used.") + } else { + err + } + } + } + + fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) { + finish() + } + + suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) { + val error = rootCauseException(err) + if (!isExplicitlyUserInitiatedError(error)) { + getEncryptedGitPrefs().edit { + remove(PreferenceKeys.HTTPS_PASSWORD) + } + sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } + d(error) + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(this@BaseGitActivity).run { + setTitle(resources.getString(R.string.jgit_error_dialog_title)) + setMessage(ErrorMessages[error]) + setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } + setOnDismissListener { + onPromptDone() + } + show() + } + } + } else { + onPromptDone() + } + } + + /** + * Check if a given [Throwable] is the result of an error caused by the user cancelling the + * operation. + */ + private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean { + var cause: Throwable? = throwable + while (cause != null) { + if (cause is SSHException && + cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) + return true + cause = cause.cause + } + return false + } + + /** + * Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no + * longer found. + */ + private fun rootCauseException(throwable: Throwable): Throwable { + var rootCause = throwable + // JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ exceptions. + // Also, SSHJ's UserAuthException about exhausting available authentication methods hides + // more useful exceptions. + while ((rootCause is org.eclipse.jgit.errors.TransportException || + rootCause is org.eclipse.jgit.api.errors.TransportException || + rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException || + (rootCause is UserAuthException && + rootCause.message == "Exhausted available authentication methods"))) { + rootCause = rootCause.cause ?: break + } + return rootCause + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt new file mode 100644 index 00000000..4067fda9 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt @@ -0,0 +1,153 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.git.config + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Patterns +import android.view.MenuItem +import androidx.core.os.postDelayed +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.ActivityGitConfigBinding +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.ui.git.log.GitLogActivity +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.ui.git.base.BaseGitActivity +import dev.msfjarvis.aps.util.extensions.viewBinding +import kotlinx.coroutines.launch +import org.eclipse.jgit.lib.Constants +import org.eclipse.jgit.lib.Repository + +class GitConfigActivity : BaseGitActivity() { + + private val binding by viewBinding(ActivityGitConfigBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + if (GitSettings.authorName.isEmpty()) + binding.gitUserName.requestFocus() + else + binding.gitUserName.setText(GitSettings.authorName) + binding.gitUserEmail.setText(GitSettings.authorEmail) + setupTools() + binding.saveButton.setOnClickListener { + val email = binding.gitUserEmail.text.toString().trim() + val name = binding.gitUserName.text.toString().trim() + if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { + MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.invalid_email_dialog_text)) + .setPositiveButton(getString(R.string.dialog_ok), null) + .show() + } else { + GitSettings.authorEmail = email + GitSettings.authorName = name + Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() + Handler(Looper.getMainLooper()).postDelayed(500) { finish() } + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + /** + * Sets up the UI components of the tools section. + */ + private fun setupTools() { + val repo = PasswordRepository.getRepository(null) + if (repo != null) { + binding.gitHeadStatus.text = headStatusMsg(repo) + // enable the abort button only if we're rebasing + val isRebasing = repo.repositoryState.isRebasing + binding.gitAbortRebase.isEnabled = isRebasing + binding.gitAbortRebase.alpha = if (isRebasing) 1.0f else 0.5f + } + binding.gitLog.setOnClickListener { + runCatching { + startActivity(Intent(this, GitLogActivity::class.java)) + }.onFailure { ex -> + e(ex) { "Failed to start GitLogActivity" } + } + } + binding.gitAbortRebase.setOnClickListener { + lifecycleScope.launch { + launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED).fold( + success = { + MaterialAlertDialogBuilder(this@GitConfigActivity).run { + setTitle(resources.getString(R.string.git_abort_and_push_title)) + setMessage(resources.getString( + R.string.git_break_out_of_detached_success, + GitSettings.branch, + "conflicting-${GitSettings.branch}-...", + )) + setOnDismissListener { finish() } + setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } + show() + } + }, + failure = { err -> + promptOnErrorHandler(err) { + finish() + } + }, + ) + } + } + binding.gitResetToRemote.setOnClickListener { + lifecycleScope.launch { + launchGitOperation(GitOp.RESET).fold( + success = ::finishOnSuccessHandler, + failure = { err -> + promptOnErrorHandler(err) { + finish() + } + }, + ) + } + } + } + + /** + * Returns a user-friendly message about the current state of HEAD. + * + * The state is recognized to be either pointing to a branch or detached. + */ + private fun headStatusMsg(repo: Repository): String { + return runCatching { + val headRef = repo.getRef(Constants.HEAD) + if (headRef.isSymbolic) { + val branchName = headRef.target.name + val shortBranchName = Repository.shortenRefName(branchName) + getString(R.string.git_head_on_branch, shortBranchName) + } else { + val commitHash = headRef.objectId.abbreviate(8).name() + getString(R.string.git_head_detached, commitHash) + } + }.getOrElse { ex -> + e(ex) { "Error getting HEAD reference" } + getString(R.string.git_head_missing) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt new file mode 100644 index 00000000..a0750eeb --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt @@ -0,0 +1,267 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.git.config + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.MenuItem +import android.view.View +import androidx.core.os.postDelayed +import androidx.core.view.isVisible +import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.ActivityGitCloneBinding +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.util.settings.Protocol +import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.ui.git.base.BaseGitActivity +import dev.msfjarvis.aps.util.extensions.snackbar +import dev.msfjarvis.aps.util.extensions.viewBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Activity that encompasses both the initial clone as well as editing the server config for future + * changes. + */ +class GitServerConfigActivity : BaseGitActivity() { + + private val binding by viewBinding(ActivityGitCloneBinding::inflate) + + private lateinit var newAuthMode: AuthMode + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val isClone = intent?.extras?.getBoolean("cloning") ?: false + if (isClone) { + binding.saveButton.text = getString(R.string.clone_button) + } + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + newAuthMode = GitSettings.authMode + + binding.authModeGroup.apply { + when (newAuthMode) { + AuthMode.SshKey -> check(binding.authModeSshKey.id) + AuthMode.Password -> check(binding.authModePassword.id) + AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id) + AuthMode.None -> check(View.NO_ID) + } + addOnButtonCheckedListener { _, _, _ -> + when (checkedButtonId) { + binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey + binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain + binding.authModePassword.id -> newAuthMode = AuthMode.Password + View.NO_ID -> newAuthMode = AuthMode.None + } + } + } + + binding.serverUrl.setText(GitSettings.url.also { + if (it.isNullOrEmpty()) return@also + setAuthModes(it.startsWith("http://") || it.startsWith("https://")) + }) + binding.serverBranch.setText(GitSettings.branch) + + binding.serverUrl.doOnTextChanged { text, _, _, _ -> + if (text.isNullOrEmpty()) return@doOnTextChanged + setAuthModes(text.startsWith("http://") || text.startsWith("https://")) + } + + binding.saveButton.setOnClickListener { + val newUrl = binding.serverUrl.text.toString().trim() + // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://` + // in the beginning will cause the port to be seen as part of the path. Let users know + // about it and offer a quickfix. + if (newUrl.contains(PORT_REGEX)) { + if (newUrl.startsWith("https://")) { + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.https_scheme_with_port_title) + .setMessageRes(R.string.https_scheme_with_port_message) + .setPositiveButtonClickListener { + binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) + } + .build() + .show(supportFragmentManager, "SSH_SCHEME_WARNING") + return@setOnClickListener + } else if (!newUrl.startsWith("ssh://")) { + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.ssh_scheme_needed_title) + .setMessageRes(R.string.ssh_scheme_needed_message) + .setPositiveButtonClickListener { + @Suppress("SetTextI18n") + binding.serverUrl.setText("ssh://$newUrl") + } + .build() + .show(supportFragmentManager, "SSH_SCHEME_WARNING") + return@setOnClickListener + } + } + when (val updateResult = GitSettings.updateConnectionSettingsIfValid( + newAuthMode = newAuthMode, + newUrl = binding.serverUrl.text.toString().trim(), + newBranch = binding.serverBranch.text.toString().trim())) { + GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> { + Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show() + } + + is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> { + when (updateResult.newProtocol) { + Protocol.Https -> + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.ssh_scheme_needed_title) + .setMessageRes(R.string.git_server_config_save_missing_username_https) + .setPositiveButtonClickListener { + } + .build() + .show(supportFragmentManager, "HTTPS_MISSING_USERNAME") + Protocol.Ssh -> + BasicBottomSheet.Builder(this) + .setTitleRes(R.string.ssh_scheme_needed_title) + .setMessageRes(R.string.git_server_config_save_missing_username_ssh) + .setPositiveButtonClickListener { + } + .build() + .show(supportFragmentManager, "SSH_MISSING_USERNAME") + } + } + GitSettings.UpdateConnectionSettingsResult.Valid -> { + if (isClone && PasswordRepository.getRepository(null) == null) + PasswordRepository.initialize() + if (!isClone) { + Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() + Handler(Looper.getMainLooper()).postDelayed(500) { finish() } + } else { + cloneRepository() + } + } + is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> { + val message = getString( + R.string.git_server_config_save_auth_mode_mismatch, + updateResult.newProtocol, + updateResult.validModes.joinToString(", "), + ) + Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show() + } + } + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun setAuthModes(isHttps: Boolean) = with(binding) { + if (isHttps) { + authModeSshKey.isVisible = false + authModeOpenKeychain.isVisible = false + authModePassword.isVisible = true + if (authModeGroup.checkedButtonId != authModePassword.id) + authModeGroup.check(View.NO_ID) + } else { + authModeSshKey.isVisible = true + authModeOpenKeychain.isVisible = true + authModePassword.isVisible = true + if (authModeGroup.checkedButtonId == View.NO_ID) + authModeGroup.check(authModeSshKey.id) + } + } + + /** + * Clones the repository, the directory exists, deletes it + */ + private fun cloneRepository() { + val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory()) + val localDirFiles = localDir.listFiles() ?: emptyArray() + // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder + if (localDir.exists() && localDirFiles.isNotEmpty() && + !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.dialog_delete_title) + .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString())) + .setCancelable(false) + .setPositiveButton(R.string.dialog_delete) { dialog, _ -> + runCatching { + lifecycleScope.launch { + val snackbar = snackbar(message = getString(R.string.delete_directory_progress_text), length = Snackbar.LENGTH_INDEFINITE) + withContext(Dispatchers.IO) { + localDir.deleteRecursively() + } + snackbar.dismiss() + launchGitOperation(GitOp.CLONE).fold( + success = { + setResult(RESULT_OK) + finish() + }, + failure = { err -> + promptOnErrorHandler(err) { + finish() + } + } + ) + } + }.onFailure { e -> + e.printStackTrace() + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + dialog.cancel() + } + .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> + dialog.cancel() + } + .show() + } else { + runCatching { + // Silently delete & replace the lone .git folder if it exists + if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") { + localDir.deleteRecursively() + } + }.onFailure { e -> + e(e) + MaterialAlertDialogBuilder(this).setMessage(e.message).show() + } + lifecycleScope.launch { + launchGitOperation(GitOp.CLONE).fold( + success = { + setResult(RESULT_OK) + finish() + }, + failure = { promptOnErrorHandler(it) }, + ) + } + } + } + + companion object { + + private val PORT_REGEX = ":[0-9]{1,5}/".toRegex() + + fun createCloneIntent(context: Context): Intent { + return Intent(context, GitServerConfigActivity::class.java).apply { + putExtra("cloning", true) + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt new file mode 100644 index 00000000..b02733fd --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt @@ -0,0 +1,49 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.git.log + +import android.os.Bundle +import android.view.MenuItem +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import dev.msfjarvis.aps.databinding.ActivityGitLogBinding +import dev.msfjarvis.aps.ui.git.base.BaseGitActivity +import dev.msfjarvis.aps.util.extensions.viewBinding + +/** + * Displays the repository's git commits in git-log fashion. + * + * It provides basic information about each commit by way of a non-interactive RecyclerView. + */ +class GitLogActivity : BaseGitActivity() { + + private val binding by viewBinding(ActivityGitLogBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + createRecyclerView() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun createRecyclerView() { + binding.gitLogRecyclerView.apply { + setHasFixedSize(true) + addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) + adapter = GitLogAdapter() + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt new file mode 100644 index 00000000..bccbe3b4 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt @@ -0,0 +1,58 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.git.log + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.github.ajalt.timberkt.e +import dev.msfjarvis.aps.databinding.GitLogRowLayoutBinding +import dev.msfjarvis.aps.util.git.GitCommit +import dev.msfjarvis.aps.util.git.GitLogModel +import java.text.DateFormat +import java.util.Date + +private fun shortHash(hash: String): String { + return hash.substring(0 until 8) +} + +private fun stringFrom(date: Date): String { + return DateFormat.getDateTimeInstance().format(date) +} + +/** + * @see GitLogActivity + */ +class GitLogAdapter : RecyclerView.Adapter() { + + private val model = GitLogModel() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val inflater = LayoutInflater.from(parent.context) + val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { + val commit = model.get(position) + if (commit == null) { + e { "There is no git commit for view holder at position $position." } + return + } + viewHolder.bind(commit) + } + + override fun getItemCount() = model.size + + class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) { + + fun bind(commit: GitCommit) = with(binding) { + gitLogRowMessage.text = commit.shortMessage + gitLogRowHash.text = shortHash(commit.hash) + gitLogRowTime.text = stringFrom(commit.time) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt new file mode 100644 index 00000000..a7eee919 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt @@ -0,0 +1,63 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.main + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import dev.msfjarvis.aps.ui.crypto.DecryptActivity +import dev.msfjarvis.aps.ui.passwords.PasswordStore +import dev.msfjarvis.aps.util.auth.BiometricAuthenticator +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.sharedPrefs + +class LaunchActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val prefs = sharedPrefs + if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) { + BiometricAuthenticator.authenticate(this) { + when (it) { + is BiometricAuthenticator.Result.Success -> { + startTargetActivity(false) + } + is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> { + prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) } + startTargetActivity(false) + } + is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> { + finish() + } + } + } + } else { + startTargetActivity(true) + } + } + + private fun startTargetActivity(noAuth: Boolean) { + val intentToStart = if (intent.action == ACTION_DECRYPT_PASS) + Intent(this, DecryptActivity::class.java).apply { + putExtra("NAME", intent.getStringExtra("NAME")) + putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH")) + putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH")) + putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L)) + } + else + Intent(this, PasswordStore::class.java) + startActivity(intentToStart) + + Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L) + } + + companion object { + + const val ACTION_DECRYPT_PASS = "DECRYPT_PASS" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt new file mode 100644 index 00000000..b0443447 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt @@ -0,0 +1,26 @@ +/* + * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.onboarding.activity + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import dev.msfjarvis.aps.R + +class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.hide() + } + + override fun onBackPressed() { + if (supportFragmentManager.backStackEntryCount == 0) { + finishAffinity() + } else { + super.onBackPressed() + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt new file mode 100644 index 00000000..aed0b7a3 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt @@ -0,0 +1,59 @@ +/* + * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + * + */ + +package dev.msfjarvis.aps.ui.onboarding.fragments + +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.FragmentCloneBinding +import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.finish +import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.extensions.viewBinding + +class CloneFragment : Fragment(R.layout.fragment_clone) { + + private val binding by viewBinding(FragmentCloneBinding::bind) + + private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } + + private val cloneAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } + finish() + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.cloneRemote.setOnClickListener { + cloneToHiddenDir() + } + binding.createLocal.setOnClickListener { + parentFragmentManager.performTransactionWithBackStack(RepoLocationFragment.newInstance()) + } + } + + /** + * Clones a remote Git repository to the app's private directory + */ + private fun cloneToHiddenDir() { + settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) } + cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext())) + } + + companion object { + + fun newInstance(): CloneFragment = CloneFragment() + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt new file mode 100644 index 00000000..5df830b1 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt @@ -0,0 +1,66 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.onboarding.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.crypto.GetKeyIdsActivity +import dev.msfjarvis.aps.databinding.FragmentKeySelectionBinding +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.commitChange +import dev.msfjarvis.aps.util.extensions.finish +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.msfjarvis.openpgpktx.util.OpenPgpApi + +class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) { + + private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } + private val binding by viewBinding(FragmentKeySelectionBinding::bind) + + private val gpgKeySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> + lifecycleScope.launch { + withContext(Dispatchers.IO) { + val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") + gpgIdentifierFile.writeText(keyIds.joinToString("\n")) + } + settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } + requireActivity().commitChange(getString( + R.string.git_commit_gpg_id, + getString(R.string.app_name) + )) + } + } + } else { + throw IllegalStateException("Failed to initialize repository state.") + } + finish() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.selectKey.setOnClickListener { gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) } + } + + companion object { + + fun newInstance() = KeySelectionFragment() + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt new file mode 100644 index 00000000..08090d1b --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt @@ -0,0 +1,192 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + * + */ + +package dev.msfjarvis.aps.ui.onboarding.fragments + +import android.Manifest +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.settings.UserPreference +import dev.msfjarvis.aps.databinding.FragmentRepoLocationBinding +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PasswordSortOrder +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.finish +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.isPermissionGranted +import dev.msfjarvis.aps.util.extensions.listFilesRecursively +import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.File + +class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) { + + private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } + private val binding by viewBinding(FragmentRepoLocationBinding::bind) + private val sortOrder: PasswordSortOrder + get() = PasswordSortOrder.getSortOrder(settings) + + private val repositoryInitAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + initializeRepositoryInfo() + } + } + + private val externalDirectorySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == AppCompatActivity.RESULT_OK) { + if (checkExternalDirectory()) { + finish() + } else { + createRepository() + } + } + } + + private val externalDirPermGrantedAction = createPermGrantedAction { + externalDirectorySelectAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) + } + + private val repositoryUsePermGrantedAction = createPermGrantedAction { + initializeRepositoryInfo() + } + + private val repositoryChangePermGrantedAction = createPermGrantedAction { + repositoryInitAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.hidden.setOnClickListener { + createRepoInHiddenDir() + } + + binding.sdcard.setOnClickListener { + createRepoFromExternalDir() + } + } + + /** + * Initializes an empty repository in the app's private directory + */ + private fun createRepoInHiddenDir() { + settings.edit { + putBoolean(PreferenceKeys.GIT_EXTERNAL, false) + remove(PreferenceKeys.GIT_EXTERNAL_REPO) + } + initializeRepositoryInfo() + } + + /** + * Initializes an empty repository in a selected directory if one does not already exist + */ + private fun createRepoFromExternalDir() { + settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) } + val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + if (externalRepo == null) { + if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + externalDirPermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + // Unlikely we have storage permissions without user ever selecting a directory, + // but let's not assume. + externalDirectorySelectAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) + } + } else { + MaterialAlertDialogBuilder(requireActivity()) + .setTitle(resources.getString(R.string.directory_selected_title)) + .setMessage(resources.getString(R.string.directory_selected_message, externalRepo)) + .setPositiveButton(resources.getString(R.string.use)) { _, _ -> + if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + repositoryUsePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + initializeRepositoryInfo() + } + } + .setNegativeButton(resources.getString(R.string.change)) { _, _ -> + if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + repositoryChangePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + repositoryInitAction.launch(UserPreference.createDirectorySelectionIntent(requireContext())) + } + } + .show() + } + } + + private fun checkExternalDirectory(): Boolean { + if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) && + settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null) { + val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + val dir = externalRepoPath?.let { File(it) } + if (dir != null && // The directory could be opened + dir.exists() && // The directory exists + dir.isDirectory && // The directory, is really a directory + dir.listFilesRecursively().isNotEmpty() && // The directory contains files + // The directory contains a non-zero number of password files + PasswordRepository.getPasswords(dir, PasswordRepository.getRepositoryDirectory(), sortOrder).isNotEmpty() + ) { + PasswordRepository.closeRepository() + return true + } + } + return false + } + + private fun createRepository() { + val localDir = PasswordRepository.getRepositoryDirectory() + runCatching { + check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" } + PasswordRepository.createRepository(localDir) + if (!PasswordRepository.isInitialized) { + PasswordRepository.initialize() + } + parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance()) + }.onFailure { e -> + e(e) + if (!localDir.delete()) { + d { "Failed to delete local repository: $localDir" } + } + finish() + } + } + + private fun initializeRepositoryInfo() { + val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) + val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + if (externalRepo && !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + return + } + if (externalRepo && externalRepoPath != null) { + if (checkExternalDirectory()) { + finish() + return + } + } + createRepository() + } + + private fun createPermGrantedAction(block: () -> Unit) = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) { + block.invoke() + } + } + + companion object { + + fun newInstance(): RepoLocationFragment = RepoLocationFragment() + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt new file mode 100644 index 00000000..696aba17 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt @@ -0,0 +1,30 @@ +/* + * Copyright © 2019-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.onboarding.fragments + +import android.content.Intent +import android.os.Bundle +import android.view.View +import androidx.annotation.Keep +import androidx.fragment.app.Fragment +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.settings.UserPreference +import dev.msfjarvis.aps.databinding.FragmentWelcomeBinding +import dev.msfjarvis.aps.util.extensions.performTransactionWithBackStack +import dev.msfjarvis.aps.util.extensions.viewBinding + +@Keep +@Suppress("unused") +class WelcomeFragment : Fragment(R.layout.fragment_welcome) { + + private val binding by viewBinding(FragmentWelcomeBinding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.letsGo.setOnClickListener { parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) } + binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), UserPreference::class.java)) } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt new file mode 100644 index 00000000..9d880de8 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt @@ -0,0 +1,344 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.passwords + +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.os.Parcelable +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.appcompat.view.ActionMode +import androidx.core.content.edit +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.runCatching +import com.github.michaelbull.result.onFailure +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel +import dev.msfjarvis.aps.databinding.PasswordRecyclerViewBinding +import dev.msfjarvis.aps.ui.git.base.BaseGitActivity +import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.ui.util.OnOffItemAnimator +import dev.msfjarvis.aps.ui.adapters.PasswordItemRecyclerAdapter +import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet +import dev.msfjarvis.aps.ui.dialogs.ItemCreationBottomSheet +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PasswordSortOrder +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.base64 +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.io.File +import kotlinx.coroutines.launch +import me.zhanghai.android.fastscroll.FastScrollerBuilder + +class PasswordFragment : Fragment(R.layout.password_recycler_view) { + + private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter + private lateinit var listener: OnFragmentInteractionListener + private lateinit var settings: SharedPreferences + + private var recyclerViewStateToRestore: Parcelable? = null + private var actionMode: ActionMode? = null + private var scrollTarget: File? = null + + private val model: SearchableRepositoryViewModel by activityViewModels() + private val binding by viewBinding(PasswordRecyclerViewBinding::bind) + private val swipeResult = registerForActivityResult(StartActivityForResult()) { + binding.swipeRefresher.isRefreshing = false + requireStore().refreshPasswordList() + } + + val currentDir: File + get() = model.currentDir.value!! + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings = requireContext().sharedPrefs + initializePasswordList() + binding.fab.setOnClickListener { + ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") + } + childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle -> + when (bundle.getString(ACTION_KEY)) { + ACTION_FOLDER -> requireStore().createFolder() + ACTION_PASSWORD -> requireStore().createPassword() + } + } + } + + private fun initializePasswordList() { + val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git") + val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true) + binding.swipeRefresher.setOnRefreshListener { + if (!hasGitDir) { + requireStore().refreshPasswordList() + binding.swipeRefresher.isRefreshing = false + } else if (!PasswordRepository.isGitRepo()) { + BasicBottomSheet.Builder(requireContext()) + .setMessageRes(R.string.clone_git_repo) + .setPositiveButtonClickListener(getString(R.string.clone_button)) { + swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext())) + } + .build() + .show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO") + binding.swipeRefresher.isRefreshing = false + } else { + // When authentication is set to AuthMode.None then the only git operation we can + // run is a pull, so automatically fallback to that. + val operationId = when (GitSettings.authMode) { + AuthMode.None -> BaseGitActivity.GitOp.PULL + else -> BaseGitActivity.GitOp.SYNC + } + requireStore().apply { + lifecycleScope.launch { + launchGitOperation(operationId).fold( + success = { + binding.swipeRefresher.isRefreshing = false + refreshPasswordList() + }, + failure = { err -> + promptOnErrorHandler(err) { + binding.swipeRefresher.isRefreshing = false + } + }, + ) + } + } + } + } + + recyclerAdapter = PasswordItemRecyclerAdapter() + .onItemClicked { _, item -> + listener.onFragmentInteraction(item) + } + .onSelectionChanged { selection -> + // In order to not interfere with drag selection, we disable the SwipeRefreshLayout + // once an item is selected. + binding.swipeRefresher.isEnabled = selection.isEmpty + + if (actionMode == null) + actionMode = requireStore().startSupportActionMode(actionModeCallback) + ?: return@onSelectionChanged + + if (!selection.isEmpty) { + actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size()) + actionMode!!.invalidate() + } else { + actionMode!!.finish() + } + } + val recyclerView = binding.passRecycler + recyclerView.apply { + addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL)) + layoutManager = LinearLayoutManager(requireContext()) + itemAnimator = OnOffItemAnimator() + adapter = recyclerAdapter + } + + FastScrollerBuilder(recyclerView).build() + recyclerAdapter.makeSelectable(recyclerView) + registerForContextMenu(recyclerView) + + val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) + model.navigateTo(File(path), pushPreviousLocation = false) + model.searchResult.observe(viewLifecycleOwner) { result -> + // Only run animations when the new list is filtered, i.e., the user submitted a search, + // and not on folder navigations since the latter leads to too many removal animations. + (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered + recyclerAdapter.submitList(result.passwordItems) { + when { + result.isFiltered -> { + // When the result is filtered, we always scroll to the top since that is where + // the best fuzzy match appears. + recyclerView.scrollToPosition(0) + } + scrollTarget != null -> { + scrollTarget?.let { + recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) + } + scrollTarget = null + } + else -> { + // When the result is not filtered and there is a saved scroll position for it, + // we try to restore it. + recyclerViewStateToRestore?.let { + recyclerView.layoutManager!!.onRestoreInstanceState(it) + } + recyclerViewStateToRestore = null + } + } + } + } + } + + private val actionModeCallback = object : ActionMode.Callback { + // Called when the action mode is created; startActionMode() was called + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + // Inflate a menu resource providing context menu items + mode.menuInflater.inflate(R.menu.context_pass, menu) + // hide the fab + animateFab(false) + return true + } + + // Called each time the action mode is shown. Always called after onCreateActionMode, but + // may be called multiple times if the mode is invalidated. + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + menu.findItem(R.id.menu_edit_password).isVisible = + recyclerAdapter.getSelectedItems() + .all { it.type == PasswordItem.TYPE_CATEGORY } + return true + } + + // Called when the user selects a contextual menu item + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_delete_password -> { + requireStore().deletePasswords(recyclerAdapter.getSelectedItems()) + // Action picked, so close the CAB + mode.finish() + true + } + R.id.menu_move_password -> { + requireStore().movePasswords(recyclerAdapter.getSelectedItems()) + false + } + R.id.menu_edit_password -> { + requireStore().renameCategory(recyclerAdapter.getSelectedItems()) + mode.finish() + false + } + else -> false + } + } + + // Called when the user exits the action mode + override fun onDestroyActionMode(mode: ActionMode) { + recyclerAdapter.requireSelectionTracker().clearSelection() + actionMode = null + // show the fab + animateFab(true) + } + + private fun animateFab(show: Boolean) = with(binding.fab) { + val animation = AnimationUtils.loadAnimation( + context, if (show) R.anim.scale_up else R.anim.scale_down + ) + animation.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationRepeat(animation: Animation?) { + } + + override fun onAnimationEnd(animation: Animation?) { + if (!show) visibility = View.GONE + } + + override fun onAnimationStart(animation: Animation?) { + if (show) visibility = View.VISIBLE + } + }) + animate().rotationBy(if (show) -90f else 90f) + .setStartDelay(if (show) 100 else 0) + .setDuration(100) + .start() + startAnimation(animation) + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + runCatching { + listener = object : OnFragmentInteractionListener { + override fun onFragmentInteraction(item: PasswordItem) { + if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) { + //save the time when password was used + val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + preferences.edit { + putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) + } + } + + if (item.type == PasswordItem.TYPE_CATEGORY) { + navigateTo(item.file) + } else { + if (requireArguments().getBoolean("matchWith", false)) { + requireStore().matchPasswordWithApp(item) + } else { + requireStore().decryptPassword(item) + } + } + } + } + }.onFailure { + throw ClassCastException("$context must implement OnFragmentInteractionListener") + } + } + + private fun requireStore() = requireActivity() as PasswordStore + + /** + * Returns true if the back press was handled by the [Fragment]. + */ + fun onBackPressedInActivity(): Boolean { + if (!model.canNavigateBack) + return false + // The RecyclerView state is restored when the asynchronous update operation on the + // adapter is completed. + recyclerViewStateToRestore = model.navigateBack() + if (!model.canNavigateBack) + requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false) + return true + } + + fun dismissActionMode() { + actionMode?.finish() + } + + companion object { + + const val ITEM_CREATION_REQUEST_KEY = "creation_key" + const val ACTION_KEY = "action" + const val ACTION_FOLDER = "folder" + const val ACTION_PASSWORD = "password" + + fun newInstance(args: Bundle): PasswordFragment { + val fragment = PasswordFragment() + fragment.arguments = args + return fragment + } + } + + + fun navigateTo(file: File) { + requireStore().clearSearch() + model.navigateTo( + file, + recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState() + ) + requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + fun scrollToOnNextRefresh(file: File) { + scrollTarget = file + } + + interface OnFragmentInteractionListener { + + fun onFragmentInteraction(item: PasswordItem) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt new file mode 100644 index 00000000..c7ed5ee9 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt @@ -0,0 +1,697 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.passwords + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo.Builder +import android.content.pm.ShortcutManager +import android.graphics.drawable.Icon +import android.os.Build +import android.os.Bundle +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem +import android.view.MenuItem.OnActionExpandListener +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.SearchView.OnQueryTextListener +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.github.ajalt.timberkt.i +import com.github.ajalt.timberkt.w +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import dev.msfjarvis.aps.ui.main.LaunchActivity +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel +import dev.msfjarvis.aps.ui.folderselect.SelectFolderActivity +import dev.msfjarvis.aps.ui.settings.UserPreference +import dev.msfjarvis.aps.util.autofill.AutofillMatcher +import dev.msfjarvis.aps.ui.crypto.BasePgpActivity.Companion.getLongName +import dev.msfjarvis.aps.ui.crypto.DecryptActivity +import dev.msfjarvis.aps.ui.crypto.PasswordCreationActivity +import dev.msfjarvis.aps.ui.git.base.BaseGitActivity +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.ui.dialogs.BasicBottomSheet +import dev.msfjarvis.aps.ui.dialogs.FolderCreationDialogFragment +import dev.msfjarvis.aps.ui.onboarding.activity.OnboardingActivity +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.base64 +import dev.msfjarvis.aps.util.extensions.commitChange +import dev.msfjarvis.aps.util.extensions.contains +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.isInsideRepository +import dev.msfjarvis.aps.util.extensions.isPermissionGranted +import dev.msfjarvis.aps.util.extensions.listFilesRecursively +import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File +import java.lang.Character.UnicodeBlock +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.Git + +const val PASSWORD_FRAGMENT_TAG = "PasswordsList" + +class PasswordStore : BaseGitActivity() { + + private lateinit var searchItem: MenuItem + private val settings by lazy { sharedPrefs } + + private val model: SearchableRepositoryViewModel by viewModels { + ViewModelProvider.AndroidViewModelFactory(application) + } + + private val storagePermissionRequest = registerForActivityResult(RequestPermission()) { granted -> + if (granted) checkLocalRepository() + } + + private val directorySelectAction = registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + checkLocalRepository() + } + } + + private val listRefreshAction = registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + refreshPasswordList() + } + } + + private val passwordMoveAction = registerForActivityResult(StartActivityForResult()) { result -> + val intentData = result.data ?: return@registerForActivityResult + val filesToMove = requireNotNull(intentData.getStringArrayExtra("Files")) + val target = File(requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH"))) + val repositoryPath = PasswordRepository.getRepositoryDirectory().absolutePath + if (!target.isDirectory) { + e { "Tried moving passwords to a non-existing folder." } + return@registerForActivityResult + } + + d { "Moving passwords to ${intentData.getStringExtra("SELECTED_FOLDER_PATH")}" } + d { filesToMove.joinToString(", ") } + + lifecycleScope.launch(Dispatchers.IO) { + for (file in filesToMove) { + val source = File(file) + if (!source.exists()) { + e { "Tried moving something that appears non-existent." } + continue + } + val destinationFile = File(target.absolutePath + "/" + source.name) + val basename = source.nameWithoutExtension + val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename) + val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) + if (destinationFile.exists()) { + e { "Trying to move a file that already exists." } + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(this@PasswordStore) + .setTitle(resources.getString(R.string.password_exists_title)) + .setMessage(resources.getString( + R.string.password_exists_message, + destinationLongName, + sourceLongName) + ) + .setPositiveButton(R.string.dialog_ok) { _, _ -> + launch(Dispatchers.IO) { + moveFile(source, destinationFile) + } + } + .setNegativeButton(R.string.dialog_cancel, null) + .show() + } + } else { + launch(Dispatchers.IO) { + moveFile(source, destinationFile) + } + } + } + when (filesToMove.size) { + 1 -> { + val source = File(filesToMove[0]) + val basename = source.nameWithoutExtension + val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename) + val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename) + withContext(Dispatchers.Main) { + commitChange( + resources.getString(R.string.git_commit_move_text, sourceLongName, destinationLongName), + ) + } + } + else -> { + val repoDir = PasswordRepository.getRepositoryDirectory().absolutePath + val relativePath = getRelativePath("${target.absolutePath}/", repoDir) + withContext(Dispatchers.Main) { + commitChange( + resources.getString(R.string.git_commit_move_multiple_text, relativePath), + ) + } + } + } + } + refreshPasswordList() + getPasswordFragment()?.dismissActionMode() + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + // open search view on search key, or Ctr+F + if ((keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && + !searchItem.isActionViewExpanded) { + searchItem.expandActionView() + return true + } + + // open search view on any printable character and query for it + val c = event.unicodeChar.toChar() + val printable = isPrintable(c) + if (printable && !searchItem.isActionViewExpanded) { + searchItem.expandActionView() + (searchItem.actionView as SearchView).setQuery(c.toString(), true) + return true + } + return super.onKeyDown(keyCode, event) + } + + @SuppressLint("NewApi") + override fun onCreate(savedInstanceState: Bundle?) { + // If user opens app with permission granted then revokes and returns, + // prevent attempt to create password list fragment + var savedInstance = savedInstanceState + if (savedInstanceState != null && (!settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) || + !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE))) { + savedInstance = null + } + super.onCreate(savedInstance) + setContentView(R.layout.activity_pwdstore) + + model.currentDir.observe(this) { dir -> + val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile + supportActionBar!!.apply { + if (dir != basePath) + title = dir.name + else + setTitle(R.string.app_name) + } + } + } + + override fun onStart() { + super.onStart() + refreshPasswordList() + } + + override fun onResume() { + super.onResume() + if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { + hasRequiredStoragePermissions() + } else { + checkLocalRepository() + } + if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false) && ::searchItem.isInitialized) { + if (!searchItem.isActionViewExpanded) { + searchItem.expandActionView() + } + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + val menuRes = when { + GitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth + PasswordRepository.isGitRepo() -> R.menu.main_menu_git + else -> R.menu.main_menu_non_git + } + menuInflater.inflate(menuRes, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + // Invalidation forces onCreateOptionsMenu to be called again. This is cheap and quick so + // we can get by without any noticeable difference in performance. + invalidateOptionsMenu() + searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.setOnQueryTextListener( + object : OnQueryTextListener { + override fun onQueryTextSubmit(s: String): Boolean { + searchView.clearFocus() + return true + } + + override fun onQueryTextChange(s: String): Boolean { + val filter = s.trim() + // List the contents of the current directory if the user enters a blank + // search term. + if (filter.isEmpty()) + model.navigateTo( + newDirectory = model.currentDir.value!!, + pushPreviousLocation = false + ) + else + model.search(filter) + return true + } + }) + + // When using the support library, the setOnActionExpandListener() method is + // static and accepts the MenuItem object as an argument + searchItem.setOnActionExpandListener( + object : OnActionExpandListener { + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + refreshPasswordList() + return true + } + + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + return true + } + }) + if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) { + searchItem.expandActionView() + } + return super.onPrepareOptionsMenu(menu) + } + + // Handle action bar item clicks here. The action bar will + // automatically handle clicks on the Home/Up button, so long + // as you specify a parent activity in AndroidManifest.xml. + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val id = item.itemId + val initBefore = MaterialAlertDialogBuilder(this) + .setMessage(resources.getString(R.string.creation_dialog_text)) + .setPositiveButton(resources.getString(R.string.dialog_ok), null) + when (id) { + R.id.user_pref -> { + runCatching { + startActivity(Intent(this, UserPreference::class.java)) + }.onFailure { e -> + e.printStackTrace() + } + return true + } + R.id.git_push -> { + if (!PasswordRepository.isInitialized) { + initBefore.show() + return false + } + runGitOperation(GitOp.PUSH) + return true + } + R.id.git_pull -> { + if (!PasswordRepository.isInitialized) { + initBefore.show() + return false + } + runGitOperation(GitOp.PULL) + return true + } + R.id.git_sync -> { + if (!PasswordRepository.isInitialized) { + initBefore.show() + return false + } + runGitOperation(GitOp.SYNC) + return true + } + R.id.refresh -> { + refreshPasswordList() + return true + } + android.R.id.home -> onBackPressed() + else -> { + } + } + return super.onOptionsItemSelected(item) + } + + override fun onBackPressed() { + if (getPasswordFragment()?.onBackPressedInActivity() != true) + super.onBackPressed() + } + + private fun getPasswordFragment(): PasswordFragment? { + return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment + } + + fun clearSearch() { + if (searchItem.isActionViewExpanded) + searchItem.collapseActionView() + } + + private fun runGitOperation(operation: GitOp) = lifecycleScope.launch { + launchGitOperation(operation).fold( + success = { refreshPasswordList() }, + failure = { promptOnErrorHandler(it) }, + ) + } + + /** + * Validates if storage permission is granted, and requests for it if not. The return value + * is true if the permission has been granted. + */ + private fun hasRequiredStoragePermissions(): Boolean { + return if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + BasicBottomSheet.Builder(this) + .setMessageRes(R.string.access_sdcard_text) + .setPositiveButtonClickListener(getString(R.string.snackbar_action_grant)) { + storagePermissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + .build() + .show(supportFragmentManager, "STORAGE_PERMISSION_MISSING") + false + } else { + checkLocalRepository() + true + } + } + + private fun checkLocalRepository() { + val repo = PasswordRepository.initialize() + if (repo == null) { + directorySelectAction.launch(UserPreference.createDirectorySelectionIntent(this)) + } else { + checkLocalRepository(PasswordRepository.getRepositoryDirectory()) + } + } + + private fun checkLocalRepository(localDir: File?) { + if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) { + d { "Check, dir: ${localDir.absolutePath}" } + // do not push the fragment if we already have it + if (getPasswordFragment() == null || + settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)) { + settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) } + val args = Bundle() + args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) + + // if the activity was started from the autofill settings, the + // intent is to match a clicked pwd with app. pass this to fragment + if (intent.getBooleanExtra("matchWith", false)) { + args.putBoolean("matchWith", true) + } + supportActionBar?.apply { + show() + setDisplayHomeAsUpEnabled(false) + } + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + supportFragmentManager.commit { + replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG) + } + } + } else { + startActivity(Intent(this, OnboardingActivity::class.java)) + } + } + + private fun getRelativePath(fullPath: String, repositoryPath: String): String { + return fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + } + + private fun getLastChangedTimestamp(fullPath: String): Long { + val repoPath = PasswordRepository.getRepositoryDirectory() + val repository = PasswordRepository.getRepository(repoPath) + if (repository == null) { + d { "getLastChangedTimestamp: No git repository" } + return File(fullPath).lastModified() + } + val git = Git(repository) + val relativePath = getRelativePath(fullPath, repoPath.absolutePath).substring(1) // Removes leading '/' + return runCatching { + val iterator = git.log().addPath(relativePath).call().iterator() + if (!iterator.hasNext()) { + w { "getLastChangedTimestamp: No commits for file: $relativePath" } + return -1 + } + iterator.next().commitTime.toLong() * 1000 + }.getOr(-1) + } + + fun decryptPassword(item: PasswordItem) { + val decryptIntent = Intent(this, DecryptActivity::class.java) + val authDecryptIntent = Intent(this, LaunchActivity::class.java) + for (intent in arrayOf(decryptIntent, authDecryptIntent)) { + intent.putExtra("NAME", item.toString()) + intent.putExtra("FILE_PATH", item.file.absolutePath) + intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath) + intent.putExtra("LAST_CHANGED_TIMESTAMP", getLastChangedTimestamp(item.file.absolutePath)) + } + // Needs an action to be a shortcut intent + authDecryptIntent.action = LaunchActivity.ACTION_DECRYPT_PASS + + startActivity(decryptIntent) + + // Adds shortcut + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + val shortcutManager: ShortcutManager = getSystemService() ?: return + val shortcut = Builder(this, item.fullPathToParent) + .setShortLabel(item.toString()) + .setLongLabel(item.fullPathToParent + item.toString()) + .setIcon(Icon.createWithResource(this, R.drawable.ic_lock_open_24px)) + .setIntent(authDecryptIntent) + .build() + val shortcuts = shortcutManager.dynamicShortcuts + if (shortcuts.size >= shortcutManager.maxShortcutCountPerActivity && shortcuts.size > 0) { + shortcuts.removeAt(shortcuts.size - 1) + shortcuts.add(0, shortcut) + shortcutManager.dynamicShortcuts = shortcuts + } else { + shortcutManager.addDynamicShortcuts(listOf(shortcut)) + } + } + } + + private fun validateState(): Boolean { + if (!PasswordRepository.isInitialized) { + MaterialAlertDialogBuilder(this) + .setMessage(resources.getString(R.string.creation_dialog_text)) + .setPositiveButton(resources.getString(R.string.dialog_ok), null) + .show() + return false + } + return true + } + + fun createPassword() { + if (!validateState()) return + val currentDir = currentDir + i { "Adding file to : ${currentDir.absolutePath}" } + val intent = Intent(this, PasswordCreationActivity::class.java) + intent.putExtra("FILE_PATH", currentDir.absolutePath) + intent.putExtra("REPO_PATH", PasswordRepository.getRepositoryDirectory().absolutePath) + listRefreshAction.launch(intent) + } + + fun createFolder() { + if (!validateState()) return + FolderCreationDialogFragment.newInstance(currentDir.path).show(supportFragmentManager, null) + } + + fun deletePasswords(selectedItems: List) { + 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.requestInputFocusOnView(R.id.folder_name_text) + 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)) { + e { "Something went wrong while moving $source to $destinationFile." } + withContext(Dispatchers.Main) { + MaterialAlertDialogBuilder(this@PasswordStore) + .setTitle(R.string.password_move_error_title) + .setMessage(getString(R.string.password_move_error_message, source, destinationFile)) + .setCancelable(true) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } else { + AutofillMatcher.updateMatches(this, sourceDestinationMap) + } + } + + fun matchPasswordWithApp(item: PasswordItem) { + val path = item.file + .absolutePath + .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "") + .replace(".gpg", "") + val data = Intent() + data.putExtra("path", path) + setResult(RESULT_OK, data) + finish() + } + + companion object { + + const val REQUEST_ARG_PATH = "PATH" + private fun isPrintable(c: Char): Boolean { + val block = UnicodeBlock.of(c) + return (!Character.isISOControl(c) && + block != null && block !== UnicodeBlock.SPECIALS) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt new file mode 100644 index 00000000..7154f217 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt @@ -0,0 +1,75 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.ui.proxy + +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Patterns +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.core.os.postDelayed +import androidx.core.widget.doOnTextChanged +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.ActivityProxySelectorBinding +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.proxy.ProxyUtils +import dev.msfjarvis.aps.util.extensions.getEncryptedProxyPrefs +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.viewBinding + +private val IP_ADDRESS_REGEX = Patterns.IP_ADDRESS.toRegex() +private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex() + +class ProxySelectorActivity : AppCompatActivity() { + + private val binding by viewBinding(ActivityProxySelectorBinding::inflate) + private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) { applicationContext.getEncryptedProxyPrefs() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + with(binding) { + proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST)) + proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME)) + proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let { + proxyPort.setText("$it") + } + proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD)) + save.setOnClickListener { saveSettings() } + proxyHost.doOnTextChanged { text, _, _, _ -> + if (text != null) { + proxyHost.error = if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) { + null + } else { + getString(R.string.invalid_proxy_url) + } + } + } + } + + } + + private fun saveSettings() { + proxyPrefs.edit { + binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let { + GitSettings.proxyHost = it + } + binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let { + GitSettings.proxyUsername = it + } + binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let { + GitSettings.proxyPort = it.toInt() + } + binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let { + GitSettings.proxyPassword = it + } + } + ProxyUtils.setDefaultProxy() + Handler(Looper.getMainLooper()).postDelayed(500) { finish() } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/UserPreference.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/UserPreference.kt new file mode 100644 index 00000000..df51562f --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/settings/UserPreference.kt @@ -0,0 +1,676 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.settings + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.ShortcutManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.DocumentsContract +import android.provider.Settings +import android.text.TextUtils +import android.view.MenuItem +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts.OpenDocument +import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.documentfile.provider.DocumentFile +import androidx.preference.CheckBoxPreference +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import androidx.preference.SwitchPreferenceCompat +import com.github.ajalt.timberkt.Timber.tag +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.w +import com.github.androidpasswordstore.autofillparser.BrowserAutofillSupportLevel +import com.github.androidpasswordstore.autofillparser.getInstalledBrowsersWithAutofillSupportLevel +import com.github.michaelbull.result.getOr +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.BuildConfig +import dev.msfjarvis.aps.util.services.PasswordExportService +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.crypto.BasePgpActivity +import dev.msfjarvis.aps.ui.git.config.GitConfigActivity +import dev.msfjarvis.aps.ui.git.config.GitServerConfigActivity +import dev.msfjarvis.aps.util.git.sshj.SshKey +import dev.msfjarvis.aps.util.pwgenxkpwd.XkpwdDictionary +import dev.msfjarvis.aps.ui.sshkeygen.ShowSshKeyFragment +import dev.msfjarvis.aps.ui.sshkeygen.SshKeyGenActivity +import dev.msfjarvis.aps.ui.proxy.ProxySelectorActivity +import dev.msfjarvis.aps.util.auth.BiometricAuthenticator +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.autofillManager +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File + +typealias ClickListener = Preference.OnPreferenceClickListener +typealias ChangeListener = Preference.OnPreferenceChangeListener + +class UserPreference : AppCompatActivity() { + + private lateinit var prefsFragment: PrefsFragment + private var fromIntent = false + + @Suppress("DEPRECATION") + private val directorySelectAction = registerForActivityResult(OpenDocumentTree()) { uri: Uri? -> + if (uri == null) return@registerForActivityResult + + tag(TAG).d { "Selected repository URI is $uri" } + // TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile + val docId = DocumentsContract.getTreeDocumentId(uri) + val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val path = if (split.size > 1) split[1] else split[0] + val repoPath = "${Environment.getExternalStorageDirectory()}/$path" + val prefs = sharedPrefs + + tag(TAG).d { "Selected repository path is $repoPath" } + + if (Environment.getExternalStorageDirectory().path == repoPath) { + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.sdcard_root_warning_title)) + .setMessage(getString(R.string.sdcard_root_warning_message)) + .setPositiveButton("Remove everything") { _, _ -> + prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) } + } + .setNegativeButton(R.string.dialog_cancel, null) + .show() + } + prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) } + if (fromIntent) { + setResult(RESULT_OK) + finish() + } + + } + + private val sshKeyImportAction = registerForActivityResult(OpenDocument()) { uri: Uri? -> + if (uri == null) return@registerForActivityResult + runCatching { + SshKey.import(uri) + + Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show() + setResult(RESULT_OK) + finish() + }.onFailure { e -> + MaterialAlertDialogBuilder(this) + .setTitle(resources.getString(R.string.ssh_key_error_dialog_title)) + .setMessage(e.message) + .setPositiveButton(resources.getString(R.string.dialog_ok), null) + .show() + } + } + + private val storeExportAction = registerForActivityResult(object : OpenDocumentTree() { + override fun createIntent(context: Context, input: Uri?): Intent { + return super.createIntent(context, input).apply { + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + } + } + }) { uri: Uri? -> + if (uri == null) return@registerForActivityResult + val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri) + + if (targetDirectory != null) { + val service = Intent(applicationContext, PasswordExportService::class.java).apply { + action = PasswordExportService.ACTION_EXPORT_PASSWORD + putExtra("uri", uri) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(service) + } else { + startService(service) + } + } + } + + private val storeCustomXkpwdDictionaryAction = registerForActivityResult(OpenDocument()) { uri -> + if (uri == null) return@registerForActivityResult + + Toast.makeText( + this, + this.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path), + Toast.LENGTH_SHORT + ).show() + + sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) } + + val customDictPref = prefsFragment.findPreference(PreferenceKeys.PREF_KEY_CUSTOM_DICT) + setCustomDictSummary(customDictPref, uri) + // copy user selected file to internal storage + val inputStream = contentResolver.openInputStream(uri) + val customDictFile = File(filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream() + inputStream?.copyTo(customDictFile, 1024) + inputStream?.close() + customDictFile.close() + + setResult(RESULT_OK) + } + + class PrefsFragment : PreferenceFragmentCompat() { + + private var autoFillEnablePreference: SwitchPreferenceCompat? = null + private var clearSavedPassPreference: Preference? = null + private var viewSshKeyPreference: Preference? = null + private lateinit var oreoAutofillDependencies: List + private lateinit var prefsActivity: UserPreference + private lateinit var sharedPreferences: SharedPreferences + private lateinit var encryptedPreferences: SharedPreferences + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + prefsActivity = requireActivity() as UserPreference + val context = requireContext() + sharedPreferences = preferenceManager.sharedPreferences + encryptedPreferences = requireActivity().getEncryptedGitPrefs() + + addPreferencesFromResource(R.xml.preference) + + // Git preferences + val gitServerPreference = findPreference(PreferenceKeys.GIT_SERVER_INFO) + val openkeystoreIdPreference = findPreference(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) + val gitConfigPreference = findPreference(PreferenceKeys.GIT_CONFIG) + val sshKeyPreference = findPreference(PreferenceKeys.SSH_KEY) + val sshKeygenPreference = findPreference(PreferenceKeys.SSH_KEYGEN) + viewSshKeyPreference = findPreference(PreferenceKeys.SSH_SEE_KEY) + clearSavedPassPreference = findPreference(PreferenceKeys.CLEAR_SAVED_PASS) + val deleteRepoPreference = findPreference(PreferenceKeys.GIT_DELETE_REPO) + val externalGitRepositoryPreference = findPreference(PreferenceKeys.GIT_EXTERNAL) + val selectExternalGitRepositoryPreference = findPreference(PreferenceKeys.PREF_SELECT_EXTERNAL) + + if (!PasswordRepository.isGitRepo()) { + listOfNotNull( + gitServerPreference, + gitConfigPreference, + sshKeyPreference, + viewSshKeyPreference, + clearSavedPassPreference, + ).forEach { + it.parent?.removePreference(it) + } + } + + // General preferences + val showTimePreference = findPreference(PreferenceKeys.GENERAL_SHOW_TIME) + val clearClipboard20xPreference = findPreference(PreferenceKeys.CLEAR_CLIPBOARD_20X) + + // Autofill preferences + autoFillEnablePreference = findPreference(PreferenceKeys.AUTOFILL_ENABLE) + val oreoAutofillDirectoryStructurePreference = findPreference(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE) + val oreoAutofillDefaultUsername = findPreference(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) + val oreoAutofillCustomPublixSuffixes = findPreference(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) + oreoAutofillDependencies = listOfNotNull( + oreoAutofillDirectoryStructurePreference, + oreoAutofillDefaultUsername, + oreoAutofillCustomPublixSuffixes, + ) + oreoAutofillCustomPublixSuffixes?.apply { + setOnBindEditTextListener { + it.isSingleLine = false + it.setHint(R.string.preference_custom_public_suffixes_hint) + } + } + + // Misc preferences + val appVersionPreference = findPreference(PreferenceKeys.APP_VERSION) + + selectExternalGitRepositoryPreference?.summary = sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + ?: getString(R.string.no_repo_selected) + deleteRepoPreference?.isVisible = !sharedPreferences.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) + clearClipboard20xPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toInt() != 0 + openkeystoreIdPreference?.isVisible = sharedPreferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() + ?: false + + updateAutofillSettings() + updateClearSavedPassphrasePrefs() + + appVersionPreference?.summary = "Version: ${BuildConfig.VERSION_NAME}" + + sshKeyPreference?.onPreferenceClickListener = ClickListener { + prefsActivity.getSshKey() + true + } + + sshKeygenPreference?.onPreferenceClickListener = ClickListener { + prefsActivity.makeSshKey(true) + true + } + + viewSshKeyPreference?.onPreferenceClickListener = ClickListener { + val df = ShowSshKeyFragment() + df.show(parentFragmentManager, "public_key") + true + } + + clearSavedPassPreference?.onPreferenceClickListener = ClickListener { + encryptedPreferences.edit { + if (encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD) != null) + remove(PreferenceKeys.HTTPS_PASSWORD) + else if (encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) != null) + remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) + } + updateClearSavedPassphrasePrefs() + true + } + + openkeystoreIdPreference?.onPreferenceClickListener = ClickListener { + sharedPreferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) } + it.isVisible = false + true + } + + gitServerPreference?.onPreferenceClickListener = ClickListener { + startActivity(Intent(prefsActivity, GitServerConfigActivity::class.java)) + true + } + + gitConfigPreference?.onPreferenceClickListener = ClickListener { + startActivity(Intent(prefsActivity, GitConfigActivity::class.java)) + true + } + + deleteRepoPreference?.onPreferenceClickListener = ClickListener { + val repoDir = PasswordRepository.getRepositoryDirectory() + MaterialAlertDialogBuilder(prefsActivity) + .setTitle(R.string.pref_dialog_delete_title) + .setMessage(resources.getString(R.string.dialog_delete_msg, repoDir)) + .setCancelable(false) + .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ -> + runCatching { + PasswordRepository.getRepositoryDirectory().deleteRecursively() + PasswordRepository.closeRepository() + }.onFailure { + // TODO Handle the different cases of exceptions + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + requireContext().getSystemService()?.apply { + removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) + } + } + sharedPreferences.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) } + dialogInterface.cancel() + prefsActivity.finish() + } + .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> run { dialogInterface.cancel() } } + .show() + + true + } + + selectExternalGitRepositoryPreference?.summary = + sharedPreferences.getString(PreferenceKeys.GIT_EXTERNAL_REPO) + ?: context.getString(R.string.no_repo_selected) + selectExternalGitRepositoryPreference?.onPreferenceClickListener = ClickListener { + prefsActivity.selectExternalGitRepository() + true + } + + val resetRepo = Preference.OnPreferenceChangeListener { _, o -> + deleteRepoPreference?.isVisible = !(o as Boolean) + PasswordRepository.closeRepository() + sharedPreferences.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) } + true + } + + selectExternalGitRepositoryPreference?.onPreferenceChangeListener = resetRepo + externalGitRepositoryPreference?.onPreferenceChangeListener = resetRepo + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + autoFillEnablePreference?.onPreferenceClickListener = ClickListener { + onEnableAutofillClick() + true + } + } + + findPreference(PreferenceKeys.EXPORT_PASSWORDS)?.apply { + isVisible = sharedPreferences.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) + onPreferenceClickListener = Preference.OnPreferenceClickListener { + prefsActivity.exportPasswords() + true + } + } + + showTimePreference?.onPreferenceChangeListener = ChangeListener { _, newValue: Any? -> + runCatching { + val isEnabled = newValue.toString().toInt() != 0 + clearClipboard20xPreference?.isVisible = isEnabled + true + }.getOr(false) + } + + showTimePreference?.summaryProvider = Preference.SummaryProvider { + getString(R.string.pref_clipboard_timeout_summary, sharedPreferences.getString + (PreferenceKeys.GENERAL_SHOW_TIME, "45")) + } + + findPreference(PreferenceKeys.ENABLE_DEBUG_LOGGING)?.isVisible = !BuildConfig.ENABLE_DEBUG_FEATURES + + findPreference(PreferenceKeys.BIOMETRIC_AUTH)?.apply { + val canAuthenticate = BiometricAuthenticator.canAuthenticate(prefsActivity) + + if (!canAuthenticate) { + isEnabled = false + isChecked = false + summary = getString(R.string.biometric_auth_summary_error) + } else { + setOnPreferenceClickListener { + isEnabled = false + sharedPreferences.edit { + val checked = isChecked + BiometricAuthenticator.authenticate(requireActivity()) { result -> + when (result) { + is BiometricAuthenticator.Result.Success -> { + // Apply the changes + putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked) + isEnabled = true + } + else -> { + // If any error occurs, revert back to the previous state. This + // catch-all clause includes the cancellation case. + putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked) + isChecked = !checked + isEnabled = true + } + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + requireContext().getSystemService()?.apply { + removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList()) + } + } + } + true + } + } + } + + findPreference(PreferenceKeys.PROXY_SETTINGS)?.onPreferenceClickListener = ClickListener { + startActivity(Intent(requireContext(), ProxySelectorActivity::class.java)) + true + } + + val prefCustomXkpwdDictionary = findPreference(PreferenceKeys.PREF_KEY_CUSTOM_DICT) + prefCustomXkpwdDictionary?.onPreferenceClickListener = ClickListener { + prefsActivity.storeCustomDictionaryPath() + true + } + val dictUri = sharedPreferences.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: "" + + if (!TextUtils.isEmpty(dictUri)) { + setCustomDictSummary(prefCustomXkpwdDictionary, Uri.parse(dictUri)) + } + + val prefIsCustomDict = findPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT) + val prefCustomDictPicker = findPreference(PreferenceKeys.PREF_KEY_CUSTOM_DICT) + val prefPwgenType = findPreference(PreferenceKeys.PREF_KEY_PWGEN_TYPE) + updateXkPasswdPrefsVisibility(prefPwgenType?.value, prefIsCustomDict, prefCustomDictPicker) + + prefPwgenType?.onPreferenceChangeListener = ChangeListener { _, newValue -> + updateXkPasswdPrefsVisibility(newValue, prefIsCustomDict, prefCustomDictPicker) + true + } + + prefIsCustomDict?.onPreferenceChangeListener = ChangeListener { _, newValue -> + if (!(newValue as Boolean)) { + val customDictFile = File(context.filesDir, XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE) + if (customDictFile.exists() && !customDictFile.delete()) { + w { "Failed to delete custom XkPassword dictionary: $customDictFile" } + } + prefCustomDictPicker?.setSummary(R.string.xkpwgen_pref_custom_dict_picker_summary) + } + true + } + } + + private fun updateXkPasswdPrefsVisibility(newValue: Any?, prefIsCustomDict: CheckBoxPreference?, prefCustomDictPicker: Preference?) { + when (newValue as String) { + BasePgpActivity.KEY_PWGEN_TYPE_CLASSIC -> { + prefIsCustomDict?.isVisible = false + prefCustomDictPicker?.isVisible = false + } + BasePgpActivity.KEY_PWGEN_TYPE_XKPASSWD -> { + prefIsCustomDict?.isVisible = true + prefCustomDictPicker?.isVisible = true + } + } + } + + private fun updateAutofillSettings() { + val isAutofillServiceEnabled = prefsActivity.isAutofillServiceEnabled + val isAutofillSupported = prefsActivity.isAutofillServiceSupported + if (!isAutofillSupported) { + autoFillEnablePreference?.isVisible = false + } else { + autoFillEnablePreference?.isChecked = isAutofillServiceEnabled + } + oreoAutofillDependencies.forEach { + it.isVisible = isAutofillServiceEnabled + } + } + + private fun updateClearSavedPassphrasePrefs() { + clearSavedPassPreference?.apply { + val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) + val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD) + if (sshPass == null && httpsPass == null) { + isVisible = false + return@apply + } + title = when { + httpsPass != null -> getString(R.string.clear_saved_passphrase_https) + sshPass != null -> getString(R.string.clear_saved_passphrase_ssh) + else -> null + } + isVisible = true + } + } + + private fun updateViewSshPubkeyPref() { + viewSshKeyPreference?.isVisible = SshKey.canShowSshPublicKey + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun onEnableAutofillClick() { + if (prefsActivity.isAutofillServiceEnabled) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + prefsActivity.autofillManager!!.disableAutofillServices() + else + throw IllegalStateException("isAutofillServiceEnabled == true, but Build.VERSION.SDK_INT < Build.VERSION_CODES.O") + } else { + MaterialAlertDialogBuilder(prefsActivity).run { + setTitle(R.string.pref_autofill_enable_title) + @SuppressLint("InflateParams") + val layout = + layoutInflater.inflate(R.layout.oreo_autofill_instructions, null) + val supportedBrowsersTextView = + layout.findViewById(R.id.supportedBrowsers) + supportedBrowsersTextView.text = + getInstalledBrowsersWithAutofillSupportLevel(context).joinToString( + separator = "\n" + ) { + val appLabel = it.first + val supportDescription = when (it.second) { + BrowserAutofillSupportLevel.None -> getString(R.string.oreo_autofill_no_support) + BrowserAutofillSupportLevel.FlakyFill -> getString(R.string.oreo_autofill_flaky_fill_support) + BrowserAutofillSupportLevel.PasswordFill -> getString(R.string.oreo_autofill_password_fill_support) + BrowserAutofillSupportLevel.GeneralFill -> getString(R.string.oreo_autofill_general_fill_support) + BrowserAutofillSupportLevel.GeneralFillAndSave -> getString(R.string.oreo_autofill_general_fill_and_save_support) + } + "$appLabel: $supportDescription" + } + setView(layout) + setPositiveButton(R.string.dialog_ok) { _, _ -> + val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { + data = Uri.parse("package:${BuildConfig.APPLICATION_ID}") + } + startActivity(intent) + } + setNegativeButton(R.string.dialog_cancel, null) + setOnDismissListener { updateAutofillSettings() } + show() + } + } + } + + override fun onResume() { + super.onResume() + updateAutofillSettings() + updateClearSavedPassphrasePrefs() + updateViewSshPubkeyPref() + } + } + + override fun onBackPressed() { + super.onBackPressed() + setResult(RESULT_OK) + finish() + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + when (intent?.getStringExtra("operation")) { + "get_ssh_key" -> getSshKey() + "make_ssh_key" -> makeSshKey(false) + "git_external" -> { + fromIntent = true + selectExternalGitRepository() + } + } + prefsFragment = PrefsFragment() + + supportFragmentManager + .beginTransaction() + .replace(android.R.id.content, prefsFragment) + .commit() + + supportActionBar?.setDisplayHomeAsUpEnabled(true) + } + + @Suppress("Deprecation") // for Environment.getExternalStorageDirectory() + fun selectExternalGitRepository() { + MaterialAlertDialogBuilder(this) + .setTitle(this.resources.getString(R.string.external_repository_dialog_title)) + .setMessage(this.resources.getString(R.string.external_repository_dialog_text)) + .setPositiveButton(R.string.dialog_ok) { _, _ -> + directorySelectAction.launch(null) + } + .setNegativeButton(R.string.dialog_cancel, null) + .show() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + // Handle action bar item clicks here. The action bar will + // automatically handle clicks on the Home/Up button, so long + // as you specify a parent activity in AndroidManifest.xml. + return when (item.itemId) { + android.R.id.home -> { + setResult(RESULT_OK) + finish() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun importSshKey() { + sshKeyImportAction.launch(arrayOf("*/*")) + } + + /** + * Opens a file explorer to import the private key + */ + private fun getSshKey() { + if (SshKey.exists) { + MaterialAlertDialogBuilder(this).run { + setTitle(R.string.ssh_keygen_existing_title) + setMessage(R.string.ssh_keygen_existing_message) + setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> + importSshKey() + } + setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> } + show() + } + } else { + importSshKey() + } + } + + /** + * Exports the passwords + */ + private fun exportPasswords() { + storeExportAction.launch(null) + } + + /** + * Opens a key generator to generate a public/private key pair + */ + fun makeSshKey(fromPreferences: Boolean) { + val intent = Intent(applicationContext, SshKeyGenActivity::class.java) + startActivity(intent) + if (!fromPreferences) { + setResult(RESULT_OK) + finish() + } + } + + /** + * Pick custom xkpwd dictionary from sdcard + */ + private fun storeCustomDictionaryPath() { + storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*")) + } + + private val isAutofillServiceSupported: Boolean + get() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + return autofillManager?.isAutofillSupported != null + } + + private val isAutofillServiceEnabled: Boolean + get() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false + return autofillManager?.hasEnabledAutofillServices() == true + } + + companion object { + + private const val TAG = "UserPreference" + + fun createDirectorySelectionIntent(context: Context): Intent { + return Intent(context, UserPreference::class.java).run { + putExtra("operation", "git_external") + } + } + + /** + * Set custom dictionary summary + */ + @JvmStatic + private fun setCustomDictSummary(customDictPref: Preference?, uri: Uri) { + val fileName = uri.path?.substring(uri.path?.lastIndexOf(":")!! + 1) + customDictPref?.summary = "Selected dictionary: $fileName" + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt new file mode 100644 index 00000000..0486b452 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt @@ -0,0 +1,38 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.sshkeygen + +import android.app.Dialog +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.git.sshj.SshKey + +class ShowSshKeyFragment : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val activity = requireActivity() + val publicKey = SshKey.sshPublicKey + return MaterialAlertDialogBuilder(requireActivity()).run { + setMessage(getString(R.string.ssh_keygen_message, publicKey)) + setTitle(R.string.your_public_key) + setNegativeButton(R.string.ssh_keygen_later) { _, _ -> + (activity as? SshKeyGenActivity)?.finish() + } + setPositiveButton(R.string.ssh_keygen_share) { _, _ -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, publicKey) + } + startActivity(Intent.createChooser(sendIntent, null)) + (activity as? SshKeyGenActivity)?.finish() + } + create() + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt new file mode 100644 index 00000000..39819988 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt @@ -0,0 +1,164 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.sshkeygen + +import android.os.Bundle +import android.security.keystore.UserNotAuthenticatedException +import android.view.MenuItem +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.edit +import androidx.core.content.getSystemService +import androidx.lifecycle.lifecycleScope +import com.github.michaelbull.result.fold +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.databinding.ActivitySshKeygenBinding +import dev.msfjarvis.aps.util.git.sshj.SshKey +import dev.msfjarvis.aps.util.auth.BiometricAuthenticator +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.keyguardManager +import dev.msfjarvis.aps.util.extensions.viewBinding +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { + Rsa({ requireAuthentication -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) + }), + Ecdsa({ requireAuthentication -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) + }), + Ed25519({ requireAuthentication -> + SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) + }), +} + +class SshKeyGenActivity : AppCompatActivity() { + + private var keyGenType = KeyGenType.Ecdsa + private val binding by viewBinding(ActivitySshKeygenBinding::inflate) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + with(binding) { + generate.setOnClickListener { + if (SshKey.exists) { + MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { + setTitle(R.string.ssh_keygen_existing_title) + setMessage(R.string.ssh_keygen_existing_message) + setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> + lifecycleScope.launch { + generate() + } + } + setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> + finish() + } + show() + } + } else { + lifecycleScope.launch { + generate() + } + } + } + keyTypeGroup.check(R.id.key_type_ecdsa) + keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) + keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + keyGenType = when (checkedId) { + R.id.key_type_ed25519 -> KeyGenType.Ed25519 + R.id.key_type_ecdsa -> KeyGenType.Ecdsa + R.id.key_type_rsa -> KeyGenType.Rsa + else -> throw IllegalStateException("Impossible key type selection") + } + keyTypeExplanation.setText(when (keyGenType) { + KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519 + KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa + KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa + }) + } + } + keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure + keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + // The back arrow in the action bar should act the same as the back button. + return when (item.itemId) { + android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private suspend fun generate() { + binding.generate.apply { + text = getString(R.string.ssh_key_gen_generating_progress) + isEnabled = false + } + binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) + val result = runCatching { + withContext(Dispatchers.IO) { + val requireAuthentication = binding.keyRequireAuthentication.isChecked + if (requireAuthentication) { + val result = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + BiometricAuthenticator.authenticate(this@SshKeyGenActivity, R.string.biometric_prompt_title_ssh_keygen) { + cont.resume(it) + } + } + } + if (result !is BiometricAuthenticator.Result.Success) + throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure)) + } + keyGenType.generateKey(requireAuthentication) + } + } + getEncryptedGitPrefs().edit { + remove("ssh_key_local_passphrase") + } + binding.generate.apply { + text = getString(R.string.ssh_keygen_generate) + isEnabled = true + } + result.fold( + success = { + ShowSshKeyFragment().show(supportFragmentManager, "public_key") + }, + failure = { e -> + e.printStackTrace() + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.error_generate_ssh_key)) + .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message) + .setPositiveButton(getString(R.string.dialog_ok)) { _, _ -> + finish() + } + .show() + }, + ) + hideKeyboard() + } + + private fun hideKeyboard() { + val imm = getSystemService() ?: return + var view = currentFocus + if (view == null) { + view = View(this) + } + imm.hideSoftInputFromWindow(view.windowToken, 0) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt b/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt new file mode 100644 index 00000000..356a914c --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt @@ -0,0 +1,71 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.ui.util + +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView + +class OnOffItemAnimator : DefaultItemAnimator() { + + var isEnabled: Boolean = true + set(value) { + // Defer update until no animation is running anymore. + isRunning { field = value } + } + + private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean { + dispatchAnimationFinished(viewHolder) + return false + } + + override fun animateAppearance( + viewHolder: RecyclerView.ViewHolder, + preLayoutInfo: ItemHolderInfo?, + postLayoutInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo) + } else { + dontAnimate(viewHolder) + } + } + + override fun animateChange( + oldHolder: RecyclerView.ViewHolder, + newHolder: RecyclerView.ViewHolder, + preInfo: ItemHolderInfo, + postInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animateChange(oldHolder, newHolder, preInfo, postInfo) + } else { + dontAnimate(oldHolder) + } + } + + override fun animateDisappearance( + viewHolder: RecyclerView.ViewHolder, + preLayoutInfo: ItemHolderInfo, + postLayoutInfo: ItemHolderInfo? + ): Boolean { + return if (isEnabled) { + super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo) + } else { + dontAnimate(viewHolder) + } + } + + override fun animatePersistence( + viewHolder: RecyclerView.ViewHolder, + preInfo: ItemHolderInfo, + postInfo: ItemHolderInfo + ): Boolean { + return if (isEnabled) { + super.animatePersistence(viewHolder, preInfo, postInfo) + } else { + dontAnimate(viewHolder) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt b/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt new file mode 100644 index 00000000..4b987ebe --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt @@ -0,0 +1,78 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.auth + +import android.app.KeyguardManager +import androidx.annotation.StringRes +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity +import com.github.ajalt.timberkt.Timber.tag +import com.github.ajalt.timberkt.d +import dev.msfjarvis.aps.R + +object BiometricAuthenticator { + + private const val TAG = "BiometricAuthenticator" + private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK + + sealed class Result { + data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() + data class Failure(val code: Int?, val message: CharSequence) : Result() + object HardwareUnavailableOrDisabled : Result() + object Cancelled : Result() + } + + fun canAuthenticate(activity: FragmentActivity): Boolean { + return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS + } + + fun authenticate( + activity: FragmentActivity, + @StringRes dialogTitleRes: Int = R.string.biometric_prompt_title, + callback: (Result) -> Unit + ) { + val authCallback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" } + callback(when (errorCode) { + BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> { + Result.Cancelled + } + BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE, + BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> { + Result.HardwareUnavailableOrDisabled + } + else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString)) + }) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error))) + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + callback(Result.Success(result.cryptoObject)) + } + } + val deviceHasKeyguard = activity.getSystemService()?.isDeviceSecure == true + if (canAuthenticate(activity) || deviceHasKeyguard) { + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(activity.getString(dialogTitleRes)) + .setAllowedAuthenticators(validAuthenticators) + .build() + BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback).authenticate(promptInfo) + } else { + callback(Result.HardwareUnavailableOrDisabled) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt new file mode 100644 index 00000000..2b0d56a3 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt @@ -0,0 +1,189 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.autofill + +import android.content.Context +import android.content.IntentSender +import android.os.Build +import android.service.autofill.Dataset +import android.service.autofill.FillCallback +import android.service.autofill.FillResponse +import android.service.autofill.SaveInfo +import android.view.inputmethod.InlineSuggestionsRequest +import android.widget.inline.InlinePresentationSpec +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.fillWith +import com.github.michaelbull.result.fold +import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity +import dev.msfjarvis.aps.ui.autofill.AutofillFilterView +import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity +import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity +import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity +import java.io.File + +/** + * Implements [AutofillResponseBuilder]'s methods for API 30 and above + */ +@RequiresApi(Build.VERSION_CODES.R) +class Api30AutofillResponseBuilder(form: FillableForm) { + + private val formOrigin = form.formOrigin + private val scenario = form.scenario + private val ignoredIds = form.ignoredIds + private val saveFlags = form.saveFlags + private val clientState = form.toClientState() + + // We do not offer save when the only relevant field is a username field or there is no field. + private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave + private val canBeSaved = saveFlags != null && scenarioSupportsSave + + private fun makeIntentDataset( + context: Context, + action: AutofillAction, + intentSender: IntentSender, + metadata: DatasetMetadata, + imeSpec: InlinePresentationSpec?, + ): Dataset { + return Dataset.Builder(makeRemoteView(context, metadata)).run { + fillWith(scenario, action, credentials = null) + setAuthentication(intentSender) + if (imeSpec != null) { + val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata) + if (inlinePresentation != null) { + setInlinePresentation(inlinePresentation) + } + } + build() + } + } + + private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null + val metadata = makeFillMatchMetadata(context, file) + val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) + } + + private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null + val metadata = makeSearchAndFillMetadata(context) + val intentSender = + AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) + return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec) + } + + private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null + val metadata = makeGenerateAndFillMetadata(context) + val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) + return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec) + } + + + private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null + if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null + val metadata = makeFillOtpFromSmsMetadata(context) + val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) + return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec) + } + + private fun makePublisherChangedDataset( + context: Context, + publisherChangedException: AutofillPublisherChangedException, + imeSpec: InlinePresentationSpec? + ): Dataset { + val metadata = makeWarningMetadata(context) + // If the user decides to trust the new publisher, they can choose reset the list of + // matches. In this case we need to immediately show a new `FillResponse` as if the app were + // autofilled for the first time. This `FillResponse` needs to be returned as a result from + // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. + val fillResponseAfterReset = makeFillResponse(context, null, emptyList()) + val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender( + context, publisherChangedException, fillResponseAfterReset + ) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) + } + + private fun makePublisherChangedResponse( + context: Context, + inlineSuggestionsRequest: InlineSuggestionsRequest?, + publisherChangedException: AutofillPublisherChangedException + ): FillResponse { + val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull() + return FillResponse.Builder().run { + addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec)) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List): FillResponse? { + var datasetCount = 0 + val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList() + return FillResponse.Builder().run { + for (file in matchedFiles) { + makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + } + makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ + addDataset(it) + } + if (datasetCount == 0) return null + setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))) + makeSaveInfo()?.let { setSaveInfo(it) } + setClientState(clientState) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE + // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE + private fun makeSaveInfo(): SaveInfo? { + if (!canBeSaved) return null + check(saveFlags != null) + val idsToSave = scenario.fieldsToSave.toTypedArray() + if (idsToSave.isEmpty()) return null + var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD + if (scenario.hasUsername) { + saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME + } + return SaveInfo.Builder(saveDataTypes, idsToSave).run { + setFlags(saveFlags) + build() + } + } + + /** + * Creates and returns a suitable [FillResponse] to the Autofill framework. + */ + fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) { + AutofillMatcher.getMatchesFor(context, formOrigin).fold( + success = { matchedFiles -> + callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles)) + }, + failure = { e -> + e(e) + callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e)) + } + ) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt new file mode 100644 index 00000000..52f74087 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt @@ -0,0 +1,193 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.autofill + +import android.content.Context +import android.content.SharedPreferences +import android.widget.Toast +import androidx.core.content.edit +import com.github.ajalt.timberkt.Timber.e +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.w +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.computeCertificatesHash +import dev.msfjarvis.aps.R +import java.io.File + +private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches" +private val Context.autofillAppMatches + get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE) + +private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches" +private val Context.autofillWebMatches + get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE) + +private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences { + return when (formOrigin) { + is FormOrigin.App -> autofillAppMatches + is FormOrigin.Web -> autofillWebMatches + } +} + +class AutofillPublisherChangedException(val formOrigin: FormOrigin) : + Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") { + + init { + require(formOrigin is FormOrigin.App) + } +} + +/** + * Manages "matches", i.e., associations between apps or websites and Password Store entries. + */ +class AutofillMatcher { + + companion object { + + private const val MAX_NUM_MATCHES = 10 + + private const val PREFERENCE_PREFIX_TOKEN = "token;" + private fun tokenKey(formOrigin: FormOrigin.App) = + "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}" + + private const val PREFERENCE_PREFIX_MATCHES = "matches;" + private fun matchesKey(formOrigin: FormOrigin) = + "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}" + + private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean { + return when (formOrigin) { + is FormOrigin.Web -> false + is FormOrigin.App -> { + val packageName = formOrigin.identifier + val certificatesHash = computeCertificatesHash(context, packageName) + val storedCertificatesHash = + context.autofillAppMatches.getString(tokenKey(formOrigin), null) + ?: return false + val hashHasChanged = certificatesHash != storedCertificatesHash + if (hashHasChanged) { + e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" } + true + } else { + false + } + } + } + } + + private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) { + if (formOrigin is FormOrigin.App) { + val packageName = formOrigin.identifier + val certificatesHash = computeCertificatesHash(context, packageName) + context.autofillAppMatches.edit { + putString(tokenKey(formOrigin), certificatesHash) + } + } + // We don't need to store a hash for FormOrigin.Web since it can only originate from + // browsers we trust to verify the origin. + } + + /** + * Get all Password Store entries that have already been associated with [formOrigin] by the + * user. + * + * If [formOrigin] represents an app and that app's certificates have changed since the + * first time the user associated an entry with it, an [AutofillPublisherChangedException] + * will be thrown. + */ + fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result, AutofillPublisherChangedException> { + if (hasFormOriginHashChanged(context, formOrigin)) { + return Err(AutofillPublisherChangedException(formOrigin)) + } + val matchPreferences = context.matchPreferences(formOrigin) + val matchedFiles = + matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } + return Ok(matchedFiles.filter { it.exists() }.also { validFiles -> + matchPreferences.edit { + putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) + } + }) + } + + fun clearMatchesFor(context: Context, formOrigin: FormOrigin) { + context.matchPreferences(formOrigin).edit { + remove(matchesKey(formOrigin)) + if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin)) + } + } + + /** + * Associates the store entry [file] with [formOrigin], such that future Autofill responses + * to requests from this app or website offer this entry as a dataset. + * + * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of + * Android may crash when too many datasets are offered. + */ + fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) { + if (!file.exists()) return + if (hasFormOriginHashChanged(context, formOrigin)) { + // This should never happen since we already verified the publisher in + // getMatchesFor. + e { "App publisher changed between getMatchesFor and addMatchFor" } + throw AutofillPublisherChangedException(formOrigin) + } + val matchPreferences = context.matchPreferences(formOrigin) + val matchedFiles = + matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } + val newFiles = setOf(file.absoluteFile).union(matchedFiles) + if (newFiles.size > MAX_NUM_MATCHES) { + Toast.makeText( + context, + context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES), + Toast.LENGTH_LONG + ).show() + return + } + matchPreferences.edit { + putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) + } + storeFormOriginHash(context, formOrigin) + d { "Stored match for $formOrigin" } + } + + /** + * Goes through all existing matches and updates their associated entries by using + * [moveFromTo] as a lookup table and deleting the matches for files in [delete]. + */ + fun updateMatches(context: Context, moveFromTo: Map = 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) { + w { "Failed to read matches for $key" } + continue + } + // Delete all matches for file locations that are going to be overwritten, then + // transfer matches over to the files at their new locations. + val newMatches = + oldMatches.asSequence() + .minus(deletePathList) + .minus(oldNewPathMap.values) + .map { match -> + val newPath = oldNewPathMap[match] ?: return@map match + d { "Updating match for $key: $match --> $newPath" } + newPath + }.toSet() + if (newMatches != oldMatches) + prefs.edit { putStringSet(key, newMatches) } + } + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt new file mode 100644 index 00000000..aa70bacb --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt @@ -0,0 +1,140 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.autofill + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import com.github.androidpasswordstore.autofillparser.Credentials +import dev.msfjarvis.aps.data.password.PasswordEntry +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import dev.msfjarvis.aps.util.services.getDefaultUsername +import java.io.File +import java.nio.file.Paths + +enum class DirectoryStructure(val value: String) { + EncryptedUsername("encrypted_username"), + FileBased("file"), + DirectoryBased("directory"); + + /** + * Returns the username associated to [file], following the convention of the current + * [DirectoryStructure]. + * + * Examples: + * - * --> null (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) + * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased) + * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) + */ + fun getUsernameFor(file: File): String? = when (this) { + EncryptedUsername -> null + FileBased -> file.nameWithoutExtension + DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension + } + + /** + * Returns the origin identifier associated to [file], following the convention of the current + * [DirectoryStructure]. + * + * At least one of [DirectoryStructure.getIdentifierFor] and + * [DirectoryStructure.getAccountPartFor] will always return a non-null result. + * + * Examples: + * - work/example.org.gpg --> example.org (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> example.org (FileBased) + * - example.org.gpg --> example.org (FileBased, fallback) + * - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased) + * - Temporary PIN.gpg --> null (DirectoryBased) + */ + fun getIdentifierFor(file: File): String? = when (this) { + EncryptedUsername -> file.nameWithoutExtension + FileBased -> file.parentFile?.name ?: file.nameWithoutExtension + DirectoryBased -> file.parentFile?.parent + } + + /** + * Returns the path components of [file] until right before the component that contains the + * origin identifier according to the current [DirectoryStructure]. + * + * Examples: + * - work/example.org.gpg --> work (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> work (FileBased) + * - example.org/john@doe.org.gpg --> null (FileBased) + * - john@doe.org.gpg --> null (FileBased) + * - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased) + * - example.org/john@doe.org/password.gpg --> null (DirectoryBased) + */ + fun getPathToIdentifierFor(file: File): String? = when (this) { + EncryptedUsername -> file.parent + FileBased -> file.parentFile?.parent + DirectoryBased -> file.parentFile?.parentFile?.parent + } + + /** + * Returns the path component of [file] following the origin identifier according to the current + * [DirectoryStructure] (without file extension). + * + * At least one of [DirectoryStructure.getIdentifierFor] and + * [DirectoryStructure.getAccountPartFor] will always return a non-null result. + * + * Examples: + * - * --> null (EncryptedUsername) + * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) + * - example.org.gpg --> null (FileBased, fallback) + * - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased) + * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) + */ + fun getAccountPartFor(file: File): String? = when (this) { + EncryptedUsername -> null + FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null } + DirectoryBased -> file.parentFile?.let { parentFile -> + "${parentFile.name}/${file.nameWithoutExtension}" + } ?: file.nameWithoutExtension + } + + @RequiresApi(Build.VERSION_CODES.O) + fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) { + EncryptedUsername -> "/" + FileBased -> sanitizedIdentifier + DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString() + } + + fun getSaveFileName(username: String?, identifier: String) = when (this) { + EncryptedUsername -> identifier + FileBased -> username + DirectoryBased -> "password" + } + + companion object { + + const val PREFERENCE = "oreo_autofill_directory_structure" + private val DEFAULT = FileBased + + private val reverseMap = values().associateBy { it.value } + fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT + } +} + +object AutofillPreferences { + + fun directoryStructure(context: Context): DirectoryStructure { + val value = context.sharedPrefs.getString(DirectoryStructure.PREFERENCE, null) + return DirectoryStructure.fromValue(value) + } + + fun credentialsFromStoreEntry( + context: Context, + file: File, + entry: PasswordEntry, + directoryStructure: DirectoryStructure + ): Credentials { + // Always give priority to a username stored in the encrypted extras + val username = entry.username + ?: directoryStructure.getUsernameFor(file) + ?: context.getDefaultUsername() + return Credentials(username, entry.password, entry.calculateTotpCode()) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt new file mode 100644 index 00000000..eecfc81b --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt @@ -0,0 +1,205 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.autofill + +import android.content.Context +import android.content.IntentSender +import android.os.Build +import android.os.Bundle +import android.service.autofill.Dataset +import android.service.autofill.FillCallback +import android.service.autofill.FillResponse +import android.service.autofill.SaveInfo +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.AutofillScenario +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.fillWith +import com.github.michaelbull.result.fold +import dev.msfjarvis.aps.ui.autofill.AutofillDecryptActivity +import dev.msfjarvis.aps.ui.autofill.AutofillFilterView +import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity +import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity +import dev.msfjarvis.aps.autofill.oreo.ui.AutofillSmsActivity +import java.io.File + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillResponseBuilder(form: FillableForm) { + + private val formOrigin = form.formOrigin + private val scenario = form.scenario + private val ignoredIds = form.ignoredIds + private val saveFlags = form.saveFlags + private val clientState = form.toClientState() + + // We do not offer save when the only relevant field is a username field or there is no field. + private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave + private val canBeSaved = saveFlags != null && scenarioSupportsSave + + private fun makeIntentDataset( + context: Context, + action: AutofillAction, + intentSender: IntentSender, + metadata: DatasetMetadata, + ): Dataset { + return Dataset.Builder(makeRemoteView(context, metadata)).run { + fillWith(scenario, action, credentials = null) + setAuthentication(intentSender) + build() + } + } + + private fun makeMatchDataset(context: Context, file: File): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null + val metadata = makeFillMatchMetadata(context, file) + val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) + } + + + private fun makeSearchDataset(context: Context): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null + val metadata = makeSearchAndFillMetadata(context) + val intentSender = + AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) + return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata) + } + + private fun makeGenerateDataset(context: Context): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null + val metadata = makeGenerateAndFillMetadata(context) + val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) + return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata) + } + + private fun makeFillOtpFromSmsDataset(context: Context): Dataset? { + if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null + if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null + val metadata = makeFillOtpFromSmsMetadata(context) + val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) + return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata) + } + + private fun makePublisherChangedDataset( + context: Context, + publisherChangedException: AutofillPublisherChangedException, + ): Dataset { + val metadata = makeWarningMetadata(context) + // If the user decides to trust the new publisher, they can choose reset the list of + // matches. In this case we need to immediately show a new `FillResponse` as if the app were + // autofilled for the first time. This `FillResponse` needs to be returned as a result from + // `AutofillPublisherChangedActivity`, which is why we create and pass it on here. + val fillResponseAfterReset = makeFillResponse(context, emptyList()) + val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender( + context, publisherChangedException, fillResponseAfterReset + ) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) + } + + private fun makePublisherChangedResponse( + context: Context, + publisherChangedException: AutofillPublisherChangedException + ): FillResponse { + return FillResponse.Builder().run { + addDataset(makePublisherChangedDataset(context, publisherChangedException)) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE + // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE + private fun makeSaveInfo(): SaveInfo? { + if (!canBeSaved) return null + check(saveFlags != null) + val idsToSave = scenario.fieldsToSave.toTypedArray() + if (idsToSave.isEmpty()) return null + var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD + if (scenario.hasUsername) { + saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME + } + return SaveInfo.Builder(saveDataTypes, idsToSave).run { + setFlags(saveFlags) + build() + } + } + + private fun makeFillResponse(context: Context, matchedFiles: List): FillResponse? { + var datasetCount = 0 + return FillResponse.Builder().run { + for (file in matchedFiles) { + makeMatchDataset(context, file)?.let { + datasetCount++ + addDataset(it) + } + } + makeSearchDataset(context)?.let { + datasetCount++ + addDataset(it) + } + makeGenerateDataset(context)?.let { + datasetCount++ + addDataset(it) + } + makeFillOtpFromSmsDataset(context)?.let { + datasetCount++ + addDataset(it) + } + if (datasetCount == 0) return null + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))) + } + makeSaveInfo()?.let { setSaveInfo(it) } + setClientState(clientState) + setIgnoredIds(*ignoredIds.toTypedArray()) + build() + } + } + + /** + * Creates and returns a suitable [FillResponse] to the Autofill framework. + */ + fun fillCredentials(context: Context, callback: FillCallback) { + AutofillMatcher.getMatchesFor(context, formOrigin).fold( + success = { matchedFiles -> + callback.onSuccess(makeFillResponse(context, matchedFiles)) + }, + failure = { e -> + e(e) + callback.onSuccess(makePublisherChangedResponse(context, e)) + } + ) + } + + companion object { + + fun makeFillInDataset( + context: Context, + credentials: Credentials, + clientState: Bundle, + action: AutofillAction + ): Dataset { + val scenario = AutofillScenario.fromClientState(clientState) + // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even + // though they are rarely shown. + // FIXME: We should clone the original dataset here and add the credentials to be filled + // in. Otherwise, the entry in the cached list of datasets will be overwritten by the + // fill-in dataset without any visual representation. This causes it to be missing from + // the Autofill suggestions shown after the user clears the filled out form fields. + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + Dataset.Builder() + } else { + Dataset.Builder(makeRemoteView(context, makeEmptyMetadata())) + } + return builder.run { + if (scenario != null) fillWith(scenario, action, credentials) + else e { "Failed to recover scenario from client state" } + build() + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt new file mode 100644 index 00000000..6055c837 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt @@ -0,0 +1,113 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.autofill + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.graphics.drawable.Icon +import android.os.Build +import android.service.autofill.InlinePresentation +import android.view.View +import android.widget.RemoteViews +import android.widget.inline.InlinePresentationSpec +import androidx.annotation.DrawableRes +import androidx.autofill.inline.UiVersions +import androidx.autofill.inline.v1.InlineSuggestionUi +import dev.msfjarvis.aps.ui.passwords.PasswordStore +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.repo.PasswordRepository +import java.io.File + +data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int) + +fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews { + return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply { + setTextViewText(R.id.title, metadata.title) + if (metadata.subtitle != null) { + setTextViewText(R.id.summary, metadata.subtitle) + } else { + setViewVisibility(R.id.summary, View.GONE) + } + if (metadata.iconRes != Resources.ID_NULL) { + setImageViewResource(R.id.icon, metadata.iconRes) + } else { + setViewVisibility(R.id.icon, View.GONE) + } + } +} + +@SuppressLint("RestrictedApi") +fun makeInlinePresentation(context: Context, imeSpec: InlinePresentationSpec, metadata: DatasetMetadata): InlinePresentation? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) + return null + + if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) + return null + + val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0) + val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run { + setTitle(metadata.title) + if (metadata.subtitle != null) + setSubtitle(metadata.subtitle) + setContentDescription(if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title) + setStartIcon(Icon.createWithResource(context, metadata.iconRes)) + build().slice + } + + return InlinePresentation(slice, imeSpec, false) +} + + +fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata { + val directoryStructure = AutofillPreferences.directoryStructure(context) + val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) + val title = directoryStructure.getIdentifierFor(relativeFile) + ?: directoryStructure.getAccountPartFor(relativeFile)!! + val subtitle = directoryStructure.getAccountPartFor(relativeFile) + return DatasetMetadata( + title, + subtitle, + R.drawable.ic_person_black_24dp + ) +} + +fun makeSearchAndFillMetadata(context: Context) = DatasetMetadata( + context.getString(R.string.oreo_autofill_search_in_store), + null, + R.drawable.ic_search_black_24dp +) + +fun makeGenerateAndFillMetadata(context: Context) = DatasetMetadata( + context.getString(R.string.oreo_autofill_generate_password), + null, + R.drawable.ic_autofill_new_password +) + +fun makeFillOtpFromSmsMetadata(context: Context) = DatasetMetadata( + context.getString(R.string.oreo_autofill_fill_otp_from_sms), + null, + R.drawable.ic_autofill_sms +) + +fun makeEmptyMetadata() = DatasetMetadata( + "PLACEHOLDER", + "PLACEHOLDER", + R.mipmap.ic_launcher +) + +fun makeWarningMetadata(context: Context) = DatasetMetadata( + context.getString(R.string.oreo_autofill_warning_publisher_dataset_title), + context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary), + R.drawable.ic_warning_red_24dp +) + +fun makeHeaderMetadata(title: String) = DatasetMetadata( + title, + null, + 0 +) diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt new file mode 100644 index 00000000..308c5966 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt @@ -0,0 +1,179 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.extensions + +import android.app.KeyguardManager +import android.content.ClipboardManager +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.os.Build +import android.util.Base64 +import android.util.TypedValue +import android.view.View +import android.view.autofill.AutofillManager +import android.view.inputmethod.InputMethodManager +import androidx.annotation.IdRes +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AlertDialog +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.fragment.app.FragmentActivity +import androidx.preference.PreferenceManager +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.github.ajalt.timberkt.d +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.google.android.material.snackbar.Snackbar +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.git.operation.GitOperation + +/** + * Extension function for [AlertDialog] that requests focus for the + * view whose id is [id]. Solution based on a StackOverflow + * answer: https://stackoverflow.com/a/13056259/297261 + */ +fun AlertDialog.requestInputFocusOnView(@IdRes id: Int) { + setOnShowListener { + findViewById(id)?.apply { + setOnFocusChangeListener { v, _ -> + v.post { + context.getSystemService() + ?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) + } + } + requestFocus() + } + } +} + +/** + * Get an instance of [AutofillManager]. Only + * available on Android Oreo and above + */ +val Context.autofillManager: AutofillManager? + @RequiresApi(Build.VERSION_CODES.O) + get() = getSystemService() + +/** + * Get an instance of [ClipboardManager] + */ +val Context.clipboard + get() = getSystemService() + +/** + * Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at + * each call site + */ +fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation") + +/** + * Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP + * proxy. + */ +fun Context.getEncryptedProxyPrefs() = getEncryptedPrefs("http_proxy") + +/** + * Get an instance of [EncryptedSharedPreferences] with the given [fileName] + */ +private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences { + val masterKeyAlias = MasterKey.Builder(applicationContext) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + return EncryptedSharedPreferences.create( + applicationContext, + fileName, + masterKeyAlias, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) +} + +/** + * Get an instance of [KeyguardManager] + */ +val Context.keyguardManager: KeyguardManager + get() = getSystemService()!! + +/** + * Get the default [SharedPreferences] instance + */ +val Context.sharedPrefs: SharedPreferences + get() = PreferenceManager.getDefaultSharedPreferences(applicationContext) + + +/** + * Resolve [attr] from the [Context]'s theme + */ +fun Context.resolveAttribute(attr: Int): Int { + val typedValue = TypedValue() + this.theme.resolveAttribute(attr, typedValue, true) + return typedValue.data +} + +/** + * Commit changes to the store from a [FragmentActivity] using + * a custom implementation of [GitOperation] + */ +suspend fun FragmentActivity.commitChange( + message: String, +): Result { + if (!PasswordRepository.isGitRepo()) { + return Ok(Unit) + } + return object : GitOperation(this@commitChange) { + override val commands = arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Populate the changed files count + git.status(), + // Commit everything! If anything changed, that is. + git.commit().setAll(true).setMessage(message), + ) + + override fun preExecute(): Boolean { + d { "Committing with message: '$message'" } + return true + } + }.execute() +} + +/** + * Check if [permission] has been granted to the app. + */ +fun FragmentActivity.isPermissionGranted(permission: String): Boolean { + return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED +} + +/** + * Show a [Snackbar] in a [FragmentActivity] and correctly + * anchor it to a [com.google.android.material.floatingactionbutton.FloatingActionButton] + * if one exists in the [view] + */ +fun FragmentActivity.snackbar( + view: View = findViewById(android.R.id.content), + message: String, + length: Int = Snackbar.LENGTH_SHORT, +): Snackbar { + val snackbar = Snackbar.make(view, message, length) + snackbar.anchorView = findViewById(R.id.fab) + snackbar.show() + return snackbar +} + +/** + * Simplifies the common `getString(key, null) ?: defaultValue` case slightly + */ +fun SharedPreferences.getString(key: String): String? = getString(key, null) + +/** + * Convert this [String] to its [Base64] representation + */ +fun String.base64(): String { + return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt new file mode 100644 index 00000000..dbfc0f63 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt @@ -0,0 +1,90 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.extensions + +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.data.repo.PasswordRepository +import java.io.File +import java.util.Date +import org.eclipse.jgit.lib.ObjectId +import org.eclipse.jgit.revwalk.RevCommit + +/** + * The default OpenPGP provider for the app + */ +const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain" + +/** + * Clears the given [flag] from the value of this [Int] + */ +fun Int.clearFlag(flag: Int): Int { + return this and flag.inv() +} + +/** + * Checks if this [Int] contains the given [flag] + */ +infix fun Int.hasFlag(flag: Int): Boolean { + return this and flag == flag +} + +/** + * Checks whether this [File] is a directory that contains [other]. + */ +fun File.contains(other: File): Boolean { + if (!isDirectory) + return false + if (!other.exists()) + return false + val relativePath = runCatching { + other.relativeTo(this) + }.getOrElse { + return false + } + // Direct containment is equivalent to the relative path being equal to the filename. + return relativePath.path == other.name +} + +/** + * Checks if this [File] is in the password repository directory as given + * by [PasswordRepository.getRepositoryDirectory] + */ +fun File.isInsideRepository(): Boolean { + return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath) +} + +/** + * Recursively lists the files in this [File], skipping any directories it encounters. + */ +fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList() + +/** + * Unique SHA-1 hash of this commit as hexadecimal string. + * + * @see RevCommit.getId + */ +val RevCommit.hash: String + get() = ObjectId.toString(id) + +/** + * Time this commit was made with second precision. + * + * @see RevCommit.commitTime + */ +val RevCommit.time: Date + get() { + val epochSeconds = commitTime.toLong() + val epochMilliseconds = epochSeconds * 1000 + return Date(epochMilliseconds) + } + +/** + * Splits this [String] into an [Array] of [String]s, split on the UNIX LF line ending + * and stripped of any empty lines. + */ +fun String.splitLines(): Array { + return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt new file mode 100644 index 00000000..642e0f22 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt @@ -0,0 +1,36 @@ +package dev.msfjarvis.aps.util.extensions + +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.commit +import dev.msfjarvis.aps.R + +/** + * Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. + */ +fun Fragment.isPermissionGranted(permission: String): Boolean { + return requireActivity().isPermissionGranted(permission) +} + +/** + * Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] + */ +fun Fragment.finish() = requireActivity().finish() + +/** + * Perform a [commit] on this [FragmentManager] with custom animations and adding the [destinationFragment] + * to the fragment backstack + */ +fun FragmentManager.performTransactionWithBackStack(destinationFragment: Fragment, @IdRes containerViewId: Int = android.R.id.content) { + commit { + beginTransaction() + addToBackStack(destinationFragment.tag) + setCustomAnimations( + R.animator.slide_in_left, + R.animator.slide_out_left, + R.animator.slide_in_right, + R.animator.slide_out_right) + replace(containerViewId, destinationFragment) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt new file mode 100644 index 00000000..03342d31 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt @@ -0,0 +1,65 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.extensions + + +import android.view.LayoutInflater +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.viewbinding.ViewBinding +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +/** + * Imported from https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c + */ +class FragmentViewBindingDelegate( + 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) = + lazy(LazyThreadSafetyMode.NONE) { + bindingInflater.invoke(layoutInflater) + } diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt new file mode 100644 index 00000000..44eb11e1 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt @@ -0,0 +1,67 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.git + +import android.os.RemoteException +import androidx.annotation.StringRes +import dev.msfjarvis.aps.Application +import dev.msfjarvis.aps.R +import java.net.UnknownHostException + +/** + * Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute]. + */ +sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) { + + override val message = super.message!! + + companion object { + + private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt) + } + + /** + * Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. + */ + sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { + + object PullRebaseFailed : PullException(R.string.git_pull_fail_error) + } + + /** + * Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. + */ + sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { + + object NonFastForward : PushException(R.string.git_push_nff_error) + object RemoteRejected : PushException(R.string.git_push_other_error) + class Generic(message: String) : PushException(R.string.git_push_generic_error, message) + } +} + +object ErrorMessages { + + operator fun get(throwable: Throwable?): String { + val resources = Application.instance.resources + if (throwable == null) return resources.getString(R.string.git_unknown_error) + return when (val rootCause = rootCause(throwable)) { + is GitException -> rootCause.message + is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message) + else -> throwable.message ?: resources.getString(R.string.git_unknown_error) + } + } + + private fun rootCause(throwable: Throwable): Throwable { + var cause = throwable + while (cause.cause != null) { + if (cause is GitException) break + val nextCause = cause.cause!! + if (nextCause is RemoteException) break + cause = nextCause + } + return cause + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt new file mode 100644 index 00000000..429ea2c5 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt @@ -0,0 +1,117 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.git + +import android.widget.Toast +import androidx.fragment.app.FragmentActivity +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.runCatching +import com.google.android.material.snackbar.Snackbar +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.git.GitException.PullException +import dev.msfjarvis.aps.util.git.GitException.PushException +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.util.git.operation.GitOperation +import dev.msfjarvis.aps.util.extensions.snackbar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.CommitCommand +import org.eclipse.jgit.api.PullCommand +import org.eclipse.jgit.api.PushCommand +import org.eclipse.jgit.api.RebaseResult +import org.eclipse.jgit.api.StatusCommand +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.transport.RemoteRefUpdate + +class GitCommandExecutor( + private val activity: FragmentActivity, + private val operation: GitOperation, +) { + + suspend fun execute(): Result { + val snackbar = activity.snackbar( + message = activity.resources.getString(R.string.git_operation_running), + length = Snackbar.LENGTH_INDEFINITE, + ) + // Count the number of uncommitted files + var nbChanges = 0 + return runCatching { + for (command in operation.commands) { + when (command) { + is StatusCommand -> { + val res = withContext(Dispatchers.IO) { + command.call() + } + nbChanges = res.uncommittedChanges.size + } + is CommitCommand -> { + // the previous status will eventually be used to avoid a commit + if (nbChanges > 0) { + withContext(Dispatchers.IO) { + val name = GitSettings.authorName.ifEmpty { "root" } + val email = GitSettings.authorEmail.ifEmpty { "localhost" } + val identity = PersonIdent(name, email) + command.setAuthor(identity).setCommitter(identity).call() + } + } + } + is PullCommand -> { + val result = withContext(Dispatchers.IO) { + command.call() + } + val rr = result.rebaseResult + if (rr.status == RebaseResult.Status.STOPPED) { + throw PullException.PullRebaseFailed + } + } + is PushCommand -> { + val results = withContext(Dispatchers.IO) { + command.call() + } + for (result in results) { + // Code imported (modified) from Gerrit PushOp, license Apache v2 + for (rru in result.remoteUpdates) { + when (rru.status) { + RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward + RemoteRefUpdate.Status.REJECTED_NODELETE, + RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED, + RemoteRefUpdate.Status.NON_EXISTING, + RemoteRefUpdate.Status.NOT_ATTEMPTED, + -> throw PushException.Generic(rru.status.name) + RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> { + throw if ("non-fast-forward" == rru.message) { + PushException.RemoteRejected + } else { + PushException.Generic(rru.message) + } + } + RemoteRefUpdate.Status.UP_TO_DATE -> { + withContext(Dispatchers.Main) { + Toast.makeText( + activity.applicationContext, + activity.applicationContext.getString(R.string.git_push_up_to_date), + Toast.LENGTH_SHORT + ).show() + } + } + else -> { + } + } + } + } + } + else -> { + withContext(Dispatchers.IO) { + command.call() + } + } + } + } + }.also { + snackbar.dismiss() + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt new file mode 100644 index 00000000..6e8e1c0c --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommit.kt @@ -0,0 +1,18 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.git + +import java.util.Date + +/** + * Basic information about a git commit. + * + * @property hash full-length hash of the commit object. + * @property shortMessage the commit's short message (i.e. title line). + * @property authorName name of the commit's author without email address. + * @property time time when the commit was created. + */ +data class GitCommit(val hash: String, val shortMessage: String, val authorName: String, val time: Date) diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt new file mode 100644 index 00000000..f6bbd55d --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt @@ -0,0 +1,55 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.git + +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.extensions.hash +import dev.msfjarvis.aps.util.extensions.time +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.revwalk.RevCommit + +private fun commits(): Iterable { + val repo = PasswordRepository.getRepository(null) + if (repo == null) { + e { "Could not access git repository" } + return listOf() + } + return runCatching { + Git(repo).log().call() + }.getOrElse { e -> + e(e) { "Failed to obtain git commits" } + listOf() + } +} + +/** + * Provides [GitCommit]s from a git-log of the password git repository. + * + * All commits are acquired on the first request to this object. + */ +class GitLogModel { + + // All commits are acquired here at once. Acquiring the commits in batches would not have been + // entirely sensible because the amount of computation required to obtain commit number n from + // the log includes the amount of computation required to obtain commit number n-1 from the log. + // This is because the commit graph is walked from HEAD to the last commit to obtain. + // Additionally, tests with 1000 commits in the log have not produced a significant delay in the + // user experience. + private val cache: MutableList by lazy(LazyThreadSafetyMode.NONE) { + commits().map { + GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) + }.toMutableList() + } + val size = cache.size + + fun get(index: Int): GitCommit? { + if (index >= size) e { "Cannot get git commit with index $index. There are only $size." } + return cache.getOrNull(index) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt new file mode 100644 index 00000000..1aff34de --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt @@ -0,0 +1,36 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.RebaseCommand + +class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { + + override val commands = arrayOf( + // abort the rebase + git.rebase().setOperation(RebaseCommand.Operation.ABORT), + // git checkout -b conflict-branch + git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"), + // push the changes + git.push().setRemote("origin"), + // switch back to ${gitBranch} + git.checkout().setName(remoteBranch), + ) + + override fun preExecute() = if (!git.repository.repositoryState.isRebasing) { + MaterialAlertDialogBuilder(callingActivity) + .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) + .setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded)) + .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> + callingActivity.finish() + }.show() + false + } else { + true + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt new file mode 100644 index 00000000..e1dd6760 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt @@ -0,0 +1,22 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.GitCommand + +/** + * Creates a new clone operation + * + * @param uri URL to clone the repository from + * @param callingActivity the calling activity + */ +class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) { + + override val commands: Array> = arrayOf( + Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri), + ) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt new file mode 100644 index 00000000..173b7a50 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt @@ -0,0 +1,98 @@ +package dev.msfjarvis.aps.util.git.operation + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import androidx.annotation.StringRes +import androidx.core.content.edit +import androidx.core.widget.doOnTextChanged +import androidx.fragment.app.FragmentActivity +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.git.sshj.InteractivePasswordFinder +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +class CredentialFinder( + val callingActivity: FragmentActivity, + val authMode: AuthMode +) : InteractivePasswordFinder() { + + override fun askForPassword(cont: Continuation, isRetry: Boolean) { + val gitOperationPrefs = callingActivity.getEncryptedGitPrefs() + val credentialPref: String + @StringRes val messageRes: Int + @StringRes val hintRes: Int + @StringRes val rememberRes: Int + @StringRes val errorRes: Int + when (authMode) { + AuthMode.SshKey -> { + credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE + messageRes = R.string.passphrase_dialog_text + hintRes = R.string.ssh_keygen_passphrase + rememberRes = R.string.git_operation_remember_passphrase + errorRes = R.string.git_operation_wrong_passphrase + } + AuthMode.Password -> { + // Could be either an SSH or an HTTPS password + credentialPref = PreferenceKeys.HTTPS_PASSWORD + messageRes = R.string.password_dialog_text + hintRes = R.string.git_operation_hint_password + rememberRes = R.string.git_operation_remember_password + errorRes = R.string.git_operation_wrong_password + } + else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords") + } + if (isRetry) + gitOperationPrefs.edit { remove(credentialPref) } + val storedCredential = gitOperationPrefs.getString(credentialPref, null) + if (storedCredential == null) { + val layoutInflater = LayoutInflater.from(callingActivity) + + @SuppressLint("InflateParams") + val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null) + val credentialLayout = dialogView.findViewById(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 { + requestInputFocusOnView(R.id.git_auth_credential) + show() + } + } else { + cont.resume(storedCredential) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt new file mode 100644 index 00000000..44292fc6 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt @@ -0,0 +1,216 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import android.content.Intent +import android.widget.Toast +import androidx.fragment.app.FragmentActivity +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.settings.UserPreference +import dev.msfjarvis.aps.util.git.GitCommandExecutor +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.settings.GitSettings +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import dev.msfjarvis.aps.util.git.sshj.SshAuthMethod +import dev.msfjarvis.aps.util.git.sshj.SshKey +import dev.msfjarvis.aps.util.git.sshj.SshjSessionFactory +import dev.msfjarvis.aps.util.auth.BiometricAuthenticator +import dev.msfjarvis.aps.data.repo.PasswordRepository +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.userauth.password.PasswordFinder +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.GitCommand +import org.eclipse.jgit.api.TransportCommand +import org.eclipse.jgit.errors.UnsupportedCredentialItem +import org.eclipse.jgit.transport.CredentialItem +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.SshTransport +import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.URIish + +/** + * Creates a new git operation + * + * @param callingActivity the calling activity + */ +abstract class GitOperation(protected val callingActivity: FragmentActivity) { + + abstract val commands: Array> + private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") + private var sshSessionFactory: SshjSessionFactory? = null + + protected val repository = PasswordRepository.getRepository(null)!! + protected val git = Git(repository) + protected val remoteBranch = GitSettings.branch + private val authActivity get() = callingActivity as ContinuationContainerActivity + + private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() { + + private var cachedPassword: CharArray? = null + + override fun isInteractive() = true + + override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { + for (item in items) { + when (item) { + is CredentialItem.Username -> item.value = uri?.user + is CredentialItem.Password -> { + item.value = cachedPassword?.clone() + ?: passwordFinder.reqPassword(null).also { + cachedPassword = it.clone() + } + } + else -> UnsupportedCredentialItem(uri, item.javaClass.name) + } + } + return true + } + + override fun supports(vararg items: CredentialItem) = items.all { + it is CredentialItem.Username || it is CredentialItem.Password + } + + override fun reset(uri: URIish?) { + cachedPassword?.fill(0.toChar()) + cachedPassword = null + } + } + + private fun getSshKey(make: Boolean) { + runCatching { + // Ask the UserPreference to provide us with the ssh-key + val intent = Intent(callingActivity.applicationContext, UserPreference::class.java) + intent.putExtra("operation", if (make) "make_ssh_key" else "get_ssh_key") + callingActivity.startActivity(intent) + }.onFailure { e -> + e(e) + } + } + + private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) { + sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile) + commands.filterIsInstance>().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) { + if (it !is BiometricAuthenticator.Result.Failure) + cont.resume(it) + } + } + } + when (result) { + is BiometricAuthenticator.Result.Success -> { + registerAuthProviders(SshAuthMethod.SshKey(authActivity)) + } + is BiometricAuthenticator.Result.Cancelled -> { + return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + is BiometricAuthenticator.Result.Failure -> { + throw IllegalStateException("Biometric authentication failures should be ignored") + } + else -> { + // There is a chance we succeed if the user recently confirmed + // their screen lock. Doing so would have a potential to confuse + // users though, who might deduce that the screen lock + // protection is not effective. Hence, we fail with an error. + Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show() + callingActivity.finish() + } + } + } else { + registerAuthProviders(SshAuthMethod.SshKey(authActivity)) + } + } else { + onMissingSshKeyFile() + // This would correctly cancel the operation but won't surface a user-visible + // error, allowing users to make the SSH key selection. + return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity)) + AuthMode.Password -> { + val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password)) + registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) + } + AuthMode.None -> { + } + } + return execute() + } + + /** + * Called before execution of the Git operation. + * Return false to cancel. + */ + open fun preExecute() = true + + private suspend fun postExecute() { + withContext(Dispatchers.IO) { + sshSessionFactory?.close() + } + } + + companion object { + + /** + * Timeout in seconds before [TransportCommand] will abort a stalled IO operation. + */ + private const val CONNECT_TIMEOUT = 10 + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt new file mode 100644 index 00000000..7bee775a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt @@ -0,0 +1,31 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.GitCommand + +class PullOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { + + /** + * The story of why the pull operation is committing files goes like this: Once upon a time when + * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made + * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing. + * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation] + * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation, + * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly + * replicating [SyncOperation] but leaving the pushing part to [PushOperation]. + */ + override val commands: Array> = arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Populate the changed files count + git.status(), + // Commit everything! If needed, obviously. + git.commit().setAll(true).setMessage("[Android Password Store] Sync"), + // Pull and rebase on top of the remote branch + git.pull().setRebase(true).setRemote("origin"), + ) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt new file mode 100644 index 00000000..31e5fcb7 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt @@ -0,0 +1,15 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.GitCommand + +class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { + + override val commands: Array> = arrayOf( + git.push().setPushAll().setRemote("origin"), + ) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt new file mode 100644 index 00000000..9c1fb01a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt @@ -0,0 +1,23 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity +import org.eclipse.jgit.api.ResetCommand + +class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { + + override val commands = arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Fetch everything from the origin remote + git.fetch().setRemote("origin"), + // Do a hard reset to the remote branch. Equivalent to git reset --hard origin/$remoteBranch + git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD), + // Force-create $remoteBranch if it doesn't exist. This covers the case where you switched + // branches from 'master' to anything else. + git.branchCreate().setName(remoteBranch).setForce(true), + ) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt new file mode 100644 index 00000000..512d6b48 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt @@ -0,0 +1,23 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.operation + +import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity + +class SyncOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { + + override val commands = arrayOf( + // Stage all files + git.add().addFilepattern("."), + // Populate the changed files count + git.status(), + // Commit everything! If needed, obviously. + git.commit().setAll(true).setMessage("[Android Password Store] Sync"), + // Pull and rebase on top of the remote branch + git.pull().setRebase(true).setRemote("origin"), + // Push it all back + git.push().setPushAll().setRemote("origin"), + ) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt new file mode 100644 index 00000000..8bcdad05 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt @@ -0,0 +1,37 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.LayoutRes +import androidx.appcompat.app.AppCompatActivity +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.userauth.UserAuthException + +/** + * Workaround for https://msfjarvis.dev/aps/issue/1164 + */ +open class ContinuationContainerActivity : AppCompatActivity { + + constructor() : super() + constructor(@LayoutRes layoutRes: Int) : super(layoutRes) + + var stashedCont: Continuation? = null + + val continueAfterUserInteraction = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + stashedCont?.let { cont -> + stashedCont = null + val data = result.data + if (data != null) + cont.resume(data) + else + cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER)) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt new file mode 100644 index 00000000..3b0b2549 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt @@ -0,0 +1,191 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import android.app.PendingIntent +import android.content.Intent +import androidx.activity.result.IntentSenderRequest +import androidx.core.content.edit +import com.github.ajalt.timberkt.d +import dev.msfjarvis.aps.util.extensions.OPENPGP_PROVIDER +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.Closeable +import java.security.PublicKey +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.UserAuthException +import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import org.openintents.ssh.authentication.ISshAuthenticationService +import org.openintents.ssh.authentication.SshAuthenticationApi +import org.openintents.ssh.authentication.SshAuthenticationApiError +import org.openintents.ssh.authentication.SshAuthenticationConnection +import org.openintents.ssh.authentication.request.KeySelectionRequest +import org.openintents.ssh.authentication.request.Request +import org.openintents.ssh.authentication.request.SigningRequest +import org.openintents.ssh.authentication.request.SshPublicKeyRequest +import org.openintents.ssh.authentication.response.KeySelectionResponse +import org.openintents.ssh.authentication.response.Response +import org.openintents.ssh.authentication.response.SigningResponse +import org.openintents.ssh.authentication.response.SshPublicKeyResponse + +class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : KeyProvider, Closeable { + + companion object { + + suspend fun prepareAndUse(activity: ContinuationContainerActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) { + withContext(Dispatchers.Main) { + OpenKeychainKeyProvider(activity) + }.prepareAndUse(block) + } + } + + private sealed class ApiResponse { + data class Success(val response: Response) : ApiResponse() + data class GeneralError(val exception: Exception) : ApiResponse() + data class NoSuchKey(val exception: Exception) : ApiResponse() + } + + private val context = activity.applicationContext + private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER) + private val preferences = context.sharedPrefs + private lateinit var sshServiceApi: SshAuthenticationApi + + private var keyId + get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) + set(value) { + preferences.edit { + putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) + } + } + private var publicKey: PublicKey? = null + private var privateKey: OpenKeychainPrivateKey? = null + + private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) { + prepare() + use(block) + } + + private suspend fun prepare() { + sshServiceApi = suspendCoroutine { cont -> + sshServiceConnection.connect(object : SshAuthenticationConnection.OnBound { + override fun onBound(sshAgent: ISshAuthenticationService) { + d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" } + cont.resume(SshAuthenticationApi(context, sshAgent)) + } + + override fun onError() { + throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable") + } + }) + } + + if (keyId == null) { + selectKey() + } + check(keyId != null) + fetchPublicKey() + makePrivateKey() + } + + private suspend fun fetchPublicKey(isRetry: Boolean = false) { + when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) { + is ApiResponse.Success -> { + val response = sshPublicKeyResponse.response as SshPublicKeyResponse + val sshPublicKey = response.sshPublicKey!! + publicKey = parseSshPublicKey(sshPublicKey) + ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key") + } + is ApiResponse.NoSuchKey -> if (isRetry) { + throw sshPublicKeyResponse.exception + } else { + // Allow the user to reselect an authentication key and retry + selectKey() + fetchPublicKey(true) + } + is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception + } + } + + private suspend fun selectKey() { + when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) { + is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId + is ApiResponse.GeneralError -> throw keySelectionResponse.exception + is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception + } + } + + private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse { + d { "executeRequest($request) called" } + val result = withContext(Dispatchers.Main) { + // If the request required user interaction, the data returned from the PendingIntent + // is used as the real request. + sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!! + } + return parseResult(request, result).also { + d { "executeRequest($request): $it" } + } + } + + private suspend fun parseResult(request: Request, result: Intent): ApiResponse { + return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) { + SshAuthenticationApi.RESULT_CODE_SUCCESS -> { + ApiResponse.Success(when (request) { + is KeySelectionRequest -> KeySelectionResponse(result) + is SshPublicKeyRequest -> SshPublicKeyResponse(result) + is SigningRequest -> SigningResponse(result) + else -> throw IllegalArgumentException("Unsupported OpenKeychain request type") + }) + } + SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { + val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!! + val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) { + suspendCoroutine { cont -> + activity.stashedCont = cont + activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build()) + } + } + executeApiRequest(request, resultOfUserInteraction) + } + else -> { + val error = result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR) + val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}") + when (error?.error) { + SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception) + else -> ApiResponse.GeneralError(exception) + } + } + } + } + + private fun makePrivateKey() { + check(keyId != null && publicKey != null) + privateKey = object : OpenKeychainPrivateKey { + override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = + when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) { + is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature + is ApiResponse.GeneralError -> throw signingResponse.exception + is ApiResponse.NoSuchKey -> throw signingResponse.exception + } + + override fun getAlgorithm() = publicKey!!.algorithm + } + } + + override fun close() { + activity.continueAfterUserInteraction.unregister() + sshServiceConnection.disconnect() + } + + override fun getPrivate() = privateKey + + override fun getPublic() = publicKey + + override fun getType() = KeyType.fromKey(publicKey) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt new file mode 100644 index 00000000..272a04dd --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt @@ -0,0 +1,93 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import com.hierynomus.sshj.key.KeyAlgorithm +import java.io.ByteArrayOutputStream +import java.security.PrivateKey +import kotlinx.coroutines.runBlocking +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.Factory +import net.schmizz.sshj.signature.Signature +import org.openintents.ssh.authentication.SshAuthenticationApi + +interface OpenKeychainPrivateKey : PrivateKey { + + suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray + + override fun getFormat() = null + override fun getEncoded() = null +} + +class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named) : 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 new file mode 100644 index 00000000..352465e0 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt @@ -0,0 +1,336 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import android.content.Context +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.OpenableColumns +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.core.content.edit +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKey +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.Application +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File +import java.io.IOException +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import net.i2p.crypto.eddsa.EdDSAPrivateKey +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.Buffer +import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.keyprovider.KeyProvider + +private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" +private const val KEYSTORE_ALIAS = "sshkey" +private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs" + +private val androidKeystore: KeyStore by lazy(LazyThreadSafetyMode.NONE) { + KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } +} + +private val KeyStore.sshPrivateKey + get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey + +private val KeyStore.sshPublicKey + get() = getCertificate(KEYSTORE_ALIAS)?.publicKey + +fun parseSshPublicKey(sshPublicKey: String): PublicKey? { + val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) + if (sshKeyParts.size < 2) + return null + return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey() +} + +fun toSshPublicKey(publicKey: PublicKey): String { + val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData + val keyType = KeyType.fromKey(publicKey) + return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}" +} + +object SshKey { + + val sshPublicKey + get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null + val canShowSshPublicKey + get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519) + val exists + get() = type != null + val mustAuthenticate: Boolean + get() { + return runCatching { + if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) + return false + when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) { + is PrivateKey -> { + val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) + return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired + } + is SecretKey -> { + val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) + (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired + } + else -> throw IllegalStateException("SSH key does not exist in Keystore") + } + }.getOrElse { error -> + // It is fine to swallow the exception here since it will reappear when the key is + // used for SSH authentication and can then be shown in the UI. + d(error) + false + } + } + + private val context: Context + get() = Application.instance.applicationContext + + private val privateKeyFile + get() = File(context.filesDir, ".ssh_key") + private val publicKeyFile + get() = File(context.filesDir, ".ssh_key.pub") + + private var type: Type? + get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE)) + set(value) = context.sharedPrefs.edit { + putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) + } + + private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + else + false + } + + private enum class Type(val value: String) { + Imported("imported"), + KeystoreNative("keystore_native"), + KeystoreWrappedEd25519("keystore_wrapped_ed25519"), + + // Behaves like `Imported`, but allows to view the public key. + LegacyGenerated("legacy_generated"), + ; + + companion object { + + fun fromValue(value: String?): Type? = values().associateBy { it.value }[value] + } + } + + enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) { + Rsa(KeyProperties.KEY_ALGORITHM_RSA, { + setKeySize(3072) + setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + }), + Ecdsa(KeyProperties.KEY_ALGORITHM_EC, { + setKeySize(256) + setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) + setDigests(KeyProperties.DIGEST_SHA256) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + setIsStrongBoxBacked(isStrongBoxSupported) + } + }), + } + + private fun delete() { + androidKeystore.deleteEntry(KEYSTORE_ALIAS) + // Remove Tink key set used by AndroidX's EncryptedFile. + context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { + clear() + } + if (privateKeyFile.isFile) { + privateKeyFile.delete() + } + if (publicKeyFile.isFile) { + publicKeyFile.delete() + } + context.getEncryptedGitPrefs().edit { + remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) + } + type = null + } + + fun import(uri: Uri) { + // First check whether the content at uri is likely an SSH private key. + val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) + ?.use { cursor -> + // Cursor returns only a single row. + cursor.moveToFirst() + cursor.getInt(0) + } ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) + + // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. + if (fileSize > 100_000 || fileSize == 0) + throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) + + val sshKeyInputStream = context.contentResolver.openInputStream(uri) + ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) + val lines = sshKeyInputStream.bufferedReader().readLines() + + // The file must have more than 2 lines, and the first and last line must have private key + // markers. + if (lines.size < 2 || + !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || + !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) + ) + throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) + + // At this point, we are reasonably confident that we have actually been provided a private + // key and delete the old key. + delete() + // Canonicalize line endings to '\n'. + privateKeyFile.writeText(lines.joinToString("\n")) + + type = Type.Imported + } + + @Deprecated("To be used only in Migrations.kt") + fun useLegacyKey(isGenerated: Boolean) { + type = if (isGenerated) Type.LegacyGenerated else Type.Imported + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { + MasterKey.Builder(context, KEYSTORE_ALIAS).run { + setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + setRequestStrongBoxBacked(true) + setUserAuthenticationRequired(requireAuthentication, 15) + build() + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { + EncryptedFile.Builder(context, + privateKeyFile, + getOrCreateWrappingMasterKey(requireAuthentication), + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).run { + setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME) + build() + } + } + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { + delete() + + val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication) + // Generate the ed25519 key pair and encrypt the private key. + val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() + encryptedPrivateKeyFile.openFileOutput().use { os -> + os.write((keyPair.private as EdDSAPrivateKey).seed) + } + + // Write public key in SSH format to .ssh_key.pub. + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) + + type = Type.KeystoreWrappedEd25519 + } + + fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) { + delete() + + // Generate Keystore-backed private key. + val parameterSpec = KeyGenParameterSpec.Builder( + KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN + ).run { + apply(algorithm.applyToSpec) + if (requireAuthentication) { + setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL) + } else { + @Suppress("DEPRECATION") + setUserAuthenticationValidityDurationSeconds(30) + } + } + build() + } + val keyPair = KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run { + initialize(parameterSpec) + generateKeyPair() + } + + // Write public key in SSH format to .ssh_key.pub. + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) + + type = Type.KeystoreNative + } + + fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = when (type) { + Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder) + Type.KeystoreNative -> KeystoreNativeKeyProvider + Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider + null -> null + } + + private object KeystoreNativeKeyProvider : KeyProvider { + + override fun getPublic(): PublicKey = runCatching { + androidKeystore.sshPublicKey!! + }.getOrElse { error -> + e(error) + throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error) + } + + override fun getPrivate(): PrivateKey = runCatching { + androidKeystore.sshPrivateKey!! + }.getOrElse { error -> + e(error) + throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error) + } + + override fun getType(): KeyType = KeyType.fromKey(public) + } + + private object KeystoreWrappedEd25519KeyProvider : KeyProvider { + + override fun getPublic(): PublicKey = runCatching { + parseSshPublicKey(sshPublicKey!!)!! + }.getOrElse { error -> + e(error) + throw IOException("Failed to get the public key for wrapped ed25519 key", error) + } + + override fun getPrivate(): PrivateKey = runCatching { + // The current MasterKey API does not allow getting a reference to an existing one + // without specifying the KeySpec for a new one. However, the value for passed here + // for `requireAuthentication` is not used as the key already exists at this point. + val encryptedPrivateKeyFile = runBlocking { + getOrCreateWrappedPrivateKeyFile(false) + } + val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() } + EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)) + }.getOrElse { error -> + e(error) + throw IOException("Failed to unwrap wrapped ed25519 key", error) + } + + override fun getType(): KeyType = KeyType.fromKey(public) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt new file mode 100644 index 00000000..8402d232 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt @@ -0,0 +1,275 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import com.github.ajalt.timberkt.Timber +import com.github.ajalt.timberkt.d +import com.github.michaelbull.result.runCatching +import com.hierynomus.sshj.key.KeyAlgorithms +import com.hierynomus.sshj.transport.cipher.BlockCiphers +import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory +import com.hierynomus.sshj.transport.mac.Macs +import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile +import java.security.Security +import net.schmizz.keepalive.KeepAliveProvider +import net.schmizz.sshj.ConfigImpl +import net.schmizz.sshj.common.LoggerFactory +import net.schmizz.sshj.common.SecurityUtils +import net.schmizz.sshj.transport.compression.NoneCompression +import net.schmizz.sshj.transport.kex.Curve25519SHA256 +import net.schmizz.sshj.transport.kex.Curve25519SHA256.FactoryLibSsh +import net.schmizz.sshj.transport.kex.DHGexSHA256 +import net.schmizz.sshj.transport.random.JCERandom +import net.schmizz.sshj.transport.random.SingletonRandomFactory +import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile +import net.schmizz.sshj.userauth.keyprovider.PKCS5KeyFile +import net.schmizz.sshj.userauth.keyprovider.PKCS8KeyFile +import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.slf4j.Logger +import org.slf4j.Marker + + +fun setUpBouncyCastleForSshj() { + // Replace the Android BC provider with the Java BouncyCastle provider since the former does + // not include all the required algorithms. + // Note: This may affect crypto operations in other parts of the application. + val bcIndex = Security.getProviders().indexOfFirst { + it.name == BouncyCastleProvider.PROVIDER_NAME + } + if (bcIndex == -1) { + // No Android BC found, install Java BC at lowest priority. + Security.addProvider(BouncyCastleProvider()) + } else { + // Replace Android BC with Java BC, inserted at the same position. + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + // May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261 + runCatching { Class.forName("sun.security.jca.Providers") } + Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) + } + d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } + // Prevent sshj from forwarding all cryptographic operations to BC. + SecurityUtils.setRegisterBouncyCastle(false) + SecurityUtils.setSecurityProvider(null) +} + +private abstract class AbstractLogger(private val name: String) : Logger { + + abstract fun t(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun d(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun i(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun w(message: String, t: Throwable? = null, vararg args: Any?) + abstract fun e(message: String, t: Throwable? = null, vararg args: Any?) + + override fun getName() = name + + override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled + override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled + override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled + override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled + override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled + + override fun trace(msg: String) = t(msg) + override fun trace(format: String, arg: Any?) = t(format, null, arg) + override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2) + override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments) + override fun trace(msg: String, t: Throwable?) = t(msg, t) + override fun trace(marker: Marker, msg: String) = trace(msg) + override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg) + override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + trace(format, arg1, arg2) + + override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = + trace(format, *arguments) + + override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t) + + override fun debug(msg: String) = d(msg) + override fun debug(format: String, arg: Any?) = d(format, null, arg) + override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2) + override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments) + override fun debug(msg: String, t: Throwable?) = d(msg, t) + override fun debug(marker: Marker, msg: String) = debug(msg) + override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg) + override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + debug(format, arg1, arg2) + + override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = + debug(format, *arguments) + + override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t) + + override fun info(msg: String) = i(msg) + override fun info(format: String, arg: Any?) = i(format, null, arg) + override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2) + override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments) + override fun info(msg: String, t: Throwable?) = i(msg, t) + override fun info(marker: Marker, msg: String) = info(msg) + override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg) + override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + info(format, arg1, arg2) + + override fun info(marker: Marker?, format: String, vararg arguments: Any?) = + info(format, *arguments) + + override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t) + + override fun warn(msg: String) = w(msg) + override fun warn(format: String, arg: Any?) = w(format, null, arg) + override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2) + override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments) + override fun warn(msg: String, t: Throwable?) = w(msg, t) + override fun warn(marker: Marker, msg: String) = warn(msg) + override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg) + override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + warn(format, arg1, arg2) + + override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = + warn(format, *arguments) + + override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t) + + override fun error(msg: String) = e(msg) + override fun error(format: String, arg: Any?) = e(format, null, arg) + override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2) + override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments) + override fun error(msg: String, t: Throwable?) = e(msg, t) + override fun error(marker: Marker, msg: String) = error(msg) + override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg) + override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = + error(format, arg1, arg2) + + override fun error(marker: Marker?, format: String, vararg arguments: Any?) = + error(format, *arguments) + + override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t) +} + +object TimberLoggerFactory : LoggerFactory { + private class TimberLogger(name: String) : AbstractLogger(name) { + + // We defer the log level checks to Timber. + override fun isTraceEnabled() = true + override fun isDebugEnabled() = true + override fun isInfoEnabled() = true + override fun isWarnEnabled() = true + override fun isErrorEnabled() = true + + // Replace slf4j's "{}" format string style with standard Java's "%s". + // The supposedly redundant escape on the } is not redundant. + @Suppress("RegExpRedundantEscape") + private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s") + + override fun t(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).v(t, message.fix(), *args) + } + + override fun d(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).d(t, message.fix(), *args) + } + + override fun i(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).i(t, message.fix(), *args) + } + + override fun w(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).w(t, message.fix(), *args) + } + + override fun e(message: String, t: Throwable?, vararg args: Any?) { + Timber.tag(name).e(t, message.fix(), *args) + } + } + + override fun getLogger(name: String): Logger { + return TimberLogger(name) + } + + override fun getLogger(clazz: Class<*>): Logger { + return TimberLogger(clazz.name) + } + +} + +class SshjConfig : ConfigImpl() { + + init { + loggerFactory = TimberLoggerFactory + keepAliveProvider = KeepAliveProvider.HEARTBEAT + version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1" + + initKeyExchangeFactories() + initKeyAlgorithms() + initRandomFactory() + initFileKeyProviderFactories() + initCipherFactories() + initCompressionFactories() + initMACFactories() + } + + private fun initKeyExchangeFactories() { + keyExchangeFactories = listOf( + Curve25519SHA256.Factory(), + FactoryLibSsh(), + DHGexSHA256.Factory(), + // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get + // rsa-sha2-* key types to work with some servers (e.g. GitHub). + ExtInfoClientFactory(), + ) + } + + private fun initKeyAlgorithms() { + keyAlgorithms = listOf( + KeyAlgorithms.SSHRSACertV01(), + KeyAlgorithms.EdDSA25519(), + KeyAlgorithms.RSASHA512(), + KeyAlgorithms.RSASHA256(), + KeyAlgorithms.SSHRSA(), + KeyAlgorithms.ECDSASHANistp521(), + KeyAlgorithms.ECDSASHANistp384(), + KeyAlgorithms.ECDSASHANistp256(), + ).map { + OpenKeychainWrappedKeyAlgorithmFactory(it) + } + } + + private fun initRandomFactory() { + randomFactory = SingletonRandomFactory(JCERandom.Factory()) + } + + private fun initFileKeyProviderFactories() { + fileKeyProviderFactories = listOf( + OpenSSHKeyV1KeyFile.Factory(), + PKCS8KeyFile.Factory(), + PKCS5KeyFile.Factory(), + OpenSSHKeyFile.Factory(), + PuTTYKeyFile.Factory(), + ) + } + + + private fun initCipherFactories() { + cipherFactories = listOf( + BlockCiphers.AES256CTR(), + BlockCiphers.AES192CTR(), + BlockCiphers.AES128CTR(), + ) + } + + private fun initMACFactories() { + macFactories = listOf( + Macs.HMACSHA2512Etm(), + Macs.HMACSHA2256Etm(), + Macs.HMACSHA2512(), + Macs.HMACSHA2256(), + ) + } + + private fun initCompressionFactories() { + compressionFactories = listOf( + NoneCompression.Factory(), + ) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt new file mode 100644 index 00000000..7cd39653 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt @@ -0,0 +1,197 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.git.sshj + +import android.util.Base64 +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.w +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.util.settings.AuthMode +import dev.msfjarvis.aps.util.git.operation.CredentialFinder +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.TimeUnit +import kotlin.coroutines.Continuation +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import net.schmizz.sshj.SSHClient +import net.schmizz.sshj.common.Buffer.PlainBuffer +import net.schmizz.sshj.common.DisconnectReason +import net.schmizz.sshj.common.SSHException +import net.schmizz.sshj.common.SSHRuntimeException +import net.schmizz.sshj.common.SecurityUtils +import net.schmizz.sshj.connection.channel.direct.Session +import net.schmizz.sshj.transport.verification.FingerprintVerifier +import net.schmizz.sshj.transport.verification.HostKeyVerifier +import net.schmizz.sshj.userauth.method.AuthPassword +import net.schmizz.sshj.userauth.method.AuthPublickey +import net.schmizz.sshj.userauth.password.PasswordFinder +import net.schmizz.sshj.userauth.password.Resource +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.RemoteSession +import org.eclipse.jgit.transport.SshSessionFactory +import org.eclipse.jgit.transport.URIish +import org.eclipse.jgit.util.FS + +sealed class SshAuthMethod(val activity: ContinuationContainerActivity) { + class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity) + class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity) + class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity) +} + +abstract class InteractivePasswordFinder : PasswordFinder { + + private var isRetry = false + + abstract fun askForPassword(cont: Continuation, 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 { + d { "New SSH connection created" } + currentSession = it + } + } + + fun close() { + currentSession?.close() + } +} + +private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier { + if (!hostKeyFile.exists()) { + return HostKeyVerifier { _, _, key -> + val digest = runCatching { + SecurityUtils.getMessageDigest("SHA-256") + }.getOrElse { e -> + throw SSHRuntimeException(e) + } + digest.update(PlainBuffer().putPublicKey(key).compactData) + val digestData = digest.digest() + val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}" + d { "Trusting host key on first use: $hostKeyEntry" } + hostKeyFile.writeText(hostKeyEntry) + true + } + } else { + val hostKeyEntry = hostKeyFile.readText() + d { "Pinned host key: $hostKeyEntry" } + return FingerprintVerifier.getInstance(hostKeyEntry) + } +} + +private class SshjSession(uri: URIish, private val username: String, private val authMethod: SshAuthMethod, private val hostKeyFile: File) : RemoteSession { + + private lateinit var ssh: SSHClient + private var currentCommand: Session? = null + + private val uri = if (uri.host.contains('@')) { + // URIish's String constructor cannot handle '@' in the user part of the URI and the URL + // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus + // need to patch everything up ourselves. + d { "Before fixup: user=${uri.user}, host=${uri.host}" } + val userPlusHost = "${uri.user}@${uri.host}" + val realUser = userPlusHost.substringBeforeLast('@') + val realHost = userPlusHost.substringAfterLast('@') + uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } } + } else { + uri + } + + fun connect(): SshjSession { + ssh = SSHClient(SshjConfig()) + ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile)) + ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) + if (!ssh.isConnected) + throw IOException() + val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password)) + when (authMethod) { + is SshAuthMethod.Password -> { + ssh.auth(username, passwordAuth) + } + is SshAuthMethod.SshKey -> { + val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey))) + ssh.auth(username, pubkeyAuth, passwordAuth) + } + is SshAuthMethod.OpenKeychain -> { + runBlocking { + OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider -> + val openKeychainAuth = AuthPublickey(provider) + ssh.auth(username, openKeychainAuth, passwordAuth) + } + } + } + } + return this + } + + override fun exec(commandName: String?, timeout: Int): Process { + if (currentCommand != null) { + w { "Killing old command" } + disconnect() + } + val session = ssh.startSession() + currentCommand = session + return SshjProcess(session.exec(commandName), timeout.toLong()) + } + + /** + * Kills the current command if one is running and returns the session into a state where `exec` + * can be called. + * + * Note that this does *not* disconnect the session. Unfortunately, the function has to be + * called `disconnect` to override the corresponding abstract function in `RemoteSession`. + */ + override fun disconnect() { + currentCommand?.close() + currentCommand = null + } + + fun close() { + disconnect() + ssh.close() + } +} + +private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() { + + override fun waitFor(): Int { + command.join(timeout, TimeUnit.SECONDS) + command.close() + return exitValue() + } + + override fun destroy() = command.close() + + override fun getOutputStream(): OutputStream = command.outputStream + + override fun getErrorStream(): InputStream = command.errorStream + + override fun exitValue(): Int = command.exitStatus + + override fun getInputStream(): InputStream = command.inputStream +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt b/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt new file mode 100644 index 00000000..f3dae627 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt @@ -0,0 +1,66 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.proxy + +import dev.msfjarvis.aps.util.settings.GitSettings +import java.io.IOException +import java.net.Authenticator +import java.net.InetSocketAddress +import java.net.PasswordAuthentication +import java.net.Proxy +import java.net.ProxySelector +import java.net.SocketAddress +import java.net.URI + +/** + * Utility class for [Proxy] handling. + */ +object ProxyUtils { + + private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser" + private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword" + + /** + * Set the default [Proxy] and [Authenticator] for the app based on user provided settings. + */ + fun setDefaultProxy() { + ProxySelector.setDefault(object : ProxySelector() { + override fun select(uri: URI?): MutableList { + val host = GitSettings.proxyHost + val port = GitSettings.proxyPort + return if (host == null || port == -1) { + mutableListOf(Proxy.NO_PROXY) + } else { + mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port))) + } + } + + override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { + if (uri == null || sa == null || ioe == null) { + throw IllegalArgumentException("Arguments can't be null.") + } + } + }) + val user = GitSettings.proxyUsername ?: "" + val password = GitSettings.proxyPassword ?: "" + if (user.isEmpty() || password.isEmpty()) { + System.clearProperty(HTTP_PROXY_USER_PROPERTY) + System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY) + } else { + System.setProperty(HTTP_PROXY_USER_PROPERTY, user) + System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password) + } + Authenticator.setDefault(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication? { + return if (requestorType == RequestorType.PROXY) { + PasswordAuthentication(user, password.toCharArray()) + } else { + null + } + } + }) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt new file mode 100644 index 00000000..ae26c7e3 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt @@ -0,0 +1,139 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgen + +import android.content.Context +import androidx.core.content.edit +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.clearFlag +import dev.msfjarvis.aps.util.extensions.hasFlag + +enum class PasswordOption(val key: String) { + NoDigits("0"), + NoUppercaseLetters("A"), + NoAmbiguousCharacters("B"), + FullyRandom("s"), + AtLeastOneSymbol("y"), + NoLowercaseLetters("L") +} + +object PasswordGenerator { + + const val DEFAULT_LENGTH = 16 + + const val DIGITS = 0x0001 + const val UPPERS = 0x0002 + const val SYMBOLS = 0x0004 + const val NO_AMBIGUOUS = 0x0008 + const val LOWERS = 0x0020 + + const val DIGITS_STR = "0123456789" + const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz" + const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" + const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2" + + /** + * Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for + * generated passwords. + */ + fun setPrefs(ctx: Context, options: List, targetLength: Int): Boolean { + ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { + for (possibleOption in PasswordOption.values()) + putBoolean(possibleOption.key, possibleOption in options) + putInt("length", targetLength) + } + return true + } + + fun isValidPassword(password: String, pwFlags: Int): Boolean { + if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) + return false + if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) + return false + if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) + return false + if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) + return false + if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) + return false + return true + } + + /** + * Generates a password using the preferences set by [setPrefs]. + */ + @Throws(PasswordGeneratorException::class) + fun generate(ctx: Context): String { + val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) + var numCharacterCategories = 0 + + var phonemes = true + var pwgenFlags = DIGITS or UPPERS or LOWERS + + for (option in PasswordOption.values()) { + if (prefs.getBoolean(option.key, false)) { + when (option) { + PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS) + PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS) + PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS) + PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS + PasswordOption.FullyRandom -> phonemes = false + PasswordOption.AtLeastOneSymbol -> { + numCharacterCategories++ + pwgenFlags = pwgenFlags or SYMBOLS + } + } + } else { + // The No* options are false, so the respective character category will be included. + when (option) { + PasswordOption.NoDigits, + PasswordOption.NoUppercaseLetters, + PasswordOption.NoLowercaseLetters -> { + numCharacterCategories++ + } + PasswordOption.NoAmbiguousCharacters, + PasswordOption.FullyRandom, + // Since AtLeastOneSymbol is not negated, it is counted in the if branch. + PasswordOption.AtLeastOneSymbol -> { + } + } + } + } + + val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH) + if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) { + throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error)) + } + if (length < numCharacterCategories) { + throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error)) + } + if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) { + phonemes = false + pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS) + } + // Experiments show that phonemes may require more than 1000 iterations to generate a valid + // password if the length is not at least 6. + if (length < 6) { + phonemes = false + } + + var password: String? + var iterations = 0 + do { + if (iterations++ > 1000) + throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded)) + password = if (phonemes) { + RandomPhonemesGenerator.generate(length, pwgenFlags) + } else { + RandomPasswordGenerator.generate(length, pwgenFlags) + } + } while (password == null) + return password + } + + class PasswordGeneratorException(string: String) : Exception(string) +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt new file mode 100644 index 00000000..aae8d987 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt @@ -0,0 +1,33 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgen + +import java.security.SecureRandom + +private val secureRandom = SecureRandom() + +/** + * Returns a number between 0 (inclusive) and [exclusiveBound] (exclusive). + */ +fun secureRandomNumber(exclusiveBound: Int) = secureRandom.nextInt(exclusiveBound) + +/** + * Returns `true` and `false` with probablity 50% each. + */ +fun secureRandomBoolean() = secureRandom.nextBoolean() + +/** + * Returns `true` with probability [percentTrue]% and `false` with probability + * `(100 - [percentTrue])`%. + */ +fun secureRandomBiasedBoolean(percentTrue: Int): Boolean { + require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" } + require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" } + return secureRandomNumber(100) < percentTrue +} + +fun Array.secureRandomElement() = this[secureRandomNumber(size)] +fun List.secureRandomElement() = this[secureRandomNumber(size)] +fun String.secureRandomCharacter() = this[secureRandomNumber(length)] diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt new file mode 100644 index 00000000..f6341087 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt @@ -0,0 +1,45 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgen + +import dev.msfjarvis.aps.util.extensions.hasFlag + +object RandomPasswordGenerator { + + /** + * Generates a random password of length [targetLength], taking the following flags in [pwFlags] + * into account, or fails to do so and returns null: + * + * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not + * set, the password will not contain any digits. + * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase + * letter; if not set, the password will not contain any uppercase letters. + * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase + * letter; if not set, the password will not contain any lowercase letters. + * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not + * set, the password will not contain any symbols. + * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous + * characters. + */ + fun generate(targetLength: Int, pwFlags: Int): String? { + val bank = listOfNotNull( + PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS }, + PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS }, + PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS }, + PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS }, + ).joinToString("") + + var password = "" + while (password.length < targetLength) { + val candidate = bank.secureRandomCharacter() + if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && + candidate in PasswordGenerator.AMBIGUOUS_STR) { + continue + } + password += candidate + } + return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt new file mode 100644 index 00000000..408974d5 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt @@ -0,0 +1,169 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgen + +import dev.msfjarvis.aps.util.extensions.hasFlag +import java.util.Locale + +object RandomPhonemesGenerator { + + private const val CONSONANT = 0x0001 + private const val VOWEL = 0x0002 + private const val DIPHTHONG = 0x0004 + private const val NOT_FIRST = 0x0008 + + private val elements = arrayOf( + Element("a", VOWEL), + Element("ae", VOWEL or DIPHTHONG), + Element("ah", VOWEL or DIPHTHONG), + Element("ai", VOWEL or DIPHTHONG), + Element("b", CONSONANT), + Element("c", CONSONANT), + Element("ch", CONSONANT or DIPHTHONG), + Element("d", CONSONANT), + Element("e", VOWEL), + Element("ee", VOWEL or DIPHTHONG), + Element("ei", VOWEL or DIPHTHONG), + Element("f", CONSONANT), + Element("g", CONSONANT), + Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST), + Element("h", CONSONANT), + Element("i", VOWEL), + Element("ie", VOWEL or DIPHTHONG), + Element("j", CONSONANT), + Element("k", CONSONANT), + Element("l", CONSONANT), + Element("m", CONSONANT), + Element("n", CONSONANT), + Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST), + Element("o", VOWEL), + Element("oh", VOWEL or DIPHTHONG), + Element("oo", VOWEL or DIPHTHONG), + Element("p", CONSONANT), + Element("ph", CONSONANT or DIPHTHONG), + Element("qu", CONSONANT or DIPHTHONG), + Element("r", CONSONANT), + Element("s", CONSONANT), + Element("sh", CONSONANT or DIPHTHONG), + Element("t", CONSONANT), + Element("th", CONSONANT or DIPHTHONG), + Element("u", VOWEL), + Element("v", CONSONANT), + Element("w", CONSONANT), + Element("x", CONSONANT), + Element("y", CONSONANT), + Element("z", CONSONANT) + ) + + private class Element(str: String, val flags: Int) { + + val upperCase = str.toUpperCase(Locale.ROOT) + val lowerCase = str.toLowerCase(Locale.ROOT) + val length = str.length + val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR } + } + + /** + * Generates a random human-readable password of length [targetLength], taking the following + * flags in [pwFlags] into account, or fails to do so and returns null: + * + * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not + * set, the password will not contain any digits. + * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase + * letter; if not set, the password will not contain any uppercase letters. + * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase + * letter; if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any + * lowercase characters; if both are not set, an exception is thrown. + * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not + * set, the password will not contain any symbols. + * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous + * characters. + */ + fun generate(targetLength: Int, pwFlags: Int): String? { + require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS) + + var password = "" + + var isStartOfPart = true + var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT + var previousFlags = 0 + + while (password.length < targetLength) { + // First part: Add a single letter or pronounceable pair of letters in varying case. + + val candidate = elements.secureRandomElement() + + // Reroll if the candidate does not fulfill the current requirements. + if (!candidate.flags.hasFlag(nextBasicType) || + (isStartOfPart && candidate.flags hasFlag NOT_FIRST) || + // Don't let a diphthong that starts with a vowel follow a vowel. + (previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) || + // Don't add multi-character candidates if we would go over the targetLength. + (password.length + candidate.length > targetLength) || + (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)) { + continue + } + + // At this point the candidate could be appended to the password, but we still have + // to determine the case. If no upper case characters are required, we don't add + // any. + val useUpperIfBothCasesAllowed = + (isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20) + password += if (pwFlags hasFlag PasswordGenerator.UPPERS && + (!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)) { + candidate.upperCase + } else { + candidate.lowerCase + } + + // We ensured above that we will not go above the target length. + check(password.length <= targetLength) + if (password.length == targetLength) + break + + // Second part: Add digits and symbols with a certain probability (if requested) if + // they would not directly follow the first character in a pronounceable part. + + if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS && + secureRandomBiasedBoolean(30)) { + var randomDigit: Char + do { + randomDigit = secureRandomNumber(10).toString(10).first() + } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && + randomDigit in PasswordGenerator.AMBIGUOUS_STR) + + password += randomDigit + // Begin a new pronounceable part after every digit. + isStartOfPart = true + nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT + previousFlags = 0 + continue + } + + if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS && + secureRandomBiasedBoolean(20)) { + var randomSymbol: Char + do { + randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter() + } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && + randomSymbol in PasswordGenerator.AMBIGUOUS_STR) + password += randomSymbol + // Continue the password generation as if nothing was added. + } + + // Third part: Determine the basic type of the next character depending on the letter + // we just added. + nextBasicType = when { + candidate.flags.hasFlag(CONSONANT) -> VOWEL + previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) || + secureRandomBiasedBoolean(60) -> CONSONANT + else -> VOWEL + } + previousFlags = candidate.flags + isStartOfPart = false + } + return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt new file mode 100644 index 00000000..83274171 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt @@ -0,0 +1,9 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgenxkpwd + +enum class CapsType { + lowercase, UPPERCASE, TitleCase, Sentence, As_iS +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt new file mode 100644 index 00000000..4bd1a6e6 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt @@ -0,0 +1,142 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgenxkpwd + +import android.content.Context +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.pwgen.PasswordGenerator.PasswordGeneratorException +import dev.msfjarvis.aps.util.pwgen.secureRandomCharacter +import dev.msfjarvis.aps.util.pwgen.secureRandomElement +import dev.msfjarvis.aps.util.pwgen.secureRandomNumber +import java.util.Locale + +class PasswordBuilder(ctx: Context) { + + private var numSymbols = 0 + private var isAppendSymbolsSeparator = false + private var context = ctx + private var numWords = 3 + private var maxWordLength = 9 + private var minWordLength = 5 + private var separator = "." + private var capsType = CapsType.Sentence + private var prependDigits = 0 + private var numDigits = 0 + private var isPrependWithSeparator = false + private var isAppendNumberSeparator = false + + fun setNumberOfWords(amount: Int) = apply { + numWords = amount + } + + fun setMinimumWordLength(min: Int) = apply { + minWordLength = min + } + + fun setMaximumWordLength(max: Int) = apply { + maxWordLength = max + } + + fun setSeparator(separator: String) = apply { + this.separator = separator + } + + fun setCapitalization(capitalizationScheme: CapsType) = apply { + capsType = capitalizationScheme + } + + @JvmOverloads + fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply { + prependDigits = numDigits + isPrependWithSeparator = addSeparator + } + + @JvmOverloads + fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply { + this.numDigits = numDigits + isAppendNumberSeparator = addSeparator + } + + @JvmOverloads + fun appendSymbols(numSymbols: Int, addSeparator: Boolean = false) = apply { + this.numSymbols = numSymbols + isAppendSymbolsSeparator = addSeparator + } + + private fun generateRandomNumberSequence(totalNumbers: Int): String { + val numbers = StringBuilder(totalNumbers) + for (i in 0 until totalNumbers) { + numbers.append(secureRandomNumber(10)) + } + return numbers.toString() + } + + private fun generateRandomSymbolSequence(numSymbols: Int): String { + val numbers = StringBuilder(numSymbols) + for (i in 0 until numSymbols) { + numbers.append(SYMBOLS.secureRandomCharacter()) + } + return numbers.toString() + } + + @OptIn(ExperimentalStdlibApi::class) + fun create(): Result { + val wordBank = mutableListOf() + val password = StringBuilder() + + if (prependDigits != 0) { + password.append(generateRandomNumberSequence(prependDigits)) + if (isPrependWithSeparator) { + password.append(separator) + } + } + return runCatching { + val dictionary = XkpwdDictionary(context) + val words = dictionary.words + for (wordLength in minWordLength..maxWordLength) { + wordBank.addAll(words[wordLength] ?: emptyList()) + } + + if (wordBank.size == 0) { + throw PasswordGeneratorException(context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength)) + } + + for (i in 0 until numWords) { + val candidate = wordBank.secureRandomElement() + val s = when (capsType) { + CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault()) + CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate + CapsType.TitleCase -> candidate.capitalize(Locale.getDefault()) + CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault()) + CapsType.As_iS -> candidate + } + password.append(s) + if (i + 1 < numWords) { + password.append(separator) + } + } + if (numDigits != 0) { + if (isAppendNumberSeparator) { + password.append(separator) + } + password.append(generateRandomNumberSequence(numDigits)) + } + if (numSymbols != 0) { + if (isAppendSymbolsSeparator) { + password.append(separator) + } + password.append(generateRandomSymbolSequence(numSymbols)) + } + password.toString() + } + } + + companion object { + + private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt new file mode 100644 index 00000000..95e65bcb --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt @@ -0,0 +1,40 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.pwgenxkpwd + +import android.content.Context +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File + +class XkpwdDictionary(context: Context) { + + val words: Map> + + init { + val prefs = context.sharedPrefs + val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: "" + val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE) + + val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) && + uri.isNotEmpty() && customDictFile.canRead()) { + customDictFile.readLines() + } else { + context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines() + } + + words = lines.asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.contains(' ') } + .groupBy { it.length } + } + + companion object { + + const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt new file mode 100644 index 00000000..46363420 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt @@ -0,0 +1,183 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.ClipData +import android.content.Intent +import android.os.Build +import android.os.IBinder +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.content.getSystemService +import com.github.ajalt.timberkt.d +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.clipboard +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class ClipboardService : Service() { + + private val scope = CoroutineScope(Job() + Dispatchers.Main) + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent != null) { + when (intent.action) { + ACTION_CLEAR -> { + clearClipboard() + stopForeground(true) + stopSelf() + return super.onStartCommand(intent, flags, startId) + } + + ACTION_START -> { + val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45) + + if (time == 0) { + stopSelf() + } + + createNotification(time) + scope.launch { + withContext(Dispatchers.IO) { + startTimer(time) + } + withContext(Dispatchers.Main) { + clearClipboard() + stopForeground(true) + stopSelf() + } + } + return START_NOT_STICKY + } + } + } + + return super.onStartCommand(intent, flags, startId) + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private fun clearClipboard() { + val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false) + val clipboard = clipboard + + if (clipboard != null) { + scope.launch { + d { "Clearing the clipboard" } + val clip = ClipData.newPlainText("pgp_handler_result_pm", "") + clipboard.setPrimaryClip(clip) + if (deepClear) { + withContext(Dispatchers.IO) { + repeat(20) { + val count = (it * 500).toString() + clipboard.setPrimaryClip(ClipData.newPlainText(count, count)) + } + } + } + } + } else { + d { "Cannot get clipboard manager service" } + } + } + + private suspend fun startTimer(showTime: Int) { + var current = 0 + while (scope.isActive && current < showTime) { + // Block for 1s or until cancel is signalled + current++ + delay(1000) + } + } + + private fun createNotification(clearTime: Int) { + val clearTimeMs = clearTime * 1000L + val clearIntent = Intent(this, ClipboardService::class.java).apply { + action = ACTION_CLEAR + } + val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) + } else { + PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) + } + val notification = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { + createNotificationApi23(pendingIntent) + } else { + createNotificationApi24(pendingIntent, clearTimeMs) + } + + createNotificationChannel() + startForeground(1, notification) + } + + private fun createNotificationApi23(pendingIntent: PendingIntent): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.tap_clear_clipboard)) + .setSmallIcon(R.drawable.ic_action_secure_24dp) + .setContentIntent(pendingIntent) + .setUsesChronometer(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + @RequiresApi(Build.VERSION_CODES.N) + private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification { + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.tap_clear_clipboard)) + .setSmallIcon(R.drawable.ic_action_secure_24dp) + .setContentIntent(pendingIntent) + .setUsesChronometer(true) + .setChronometerCountDown(true) + .setShowWhen(true) + .setWhen(System.currentTimeMillis() + clearTimeMs) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, + getString(R.string.app_name), + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService() + if (manager != null) { + manager.createNotificationChannel(serviceChannel) + } else { + d { "Failed to create notification channel" } + } + } + } + + companion object { + + const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER" + const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME" + private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD" + private const val CHANNEL_ID = "NotificationService" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt new file mode 100644 index 00000000..3b431525 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt @@ -0,0 +1,145 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.services + +import android.content.Context +import android.os.Build +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.FillResponse +import android.service.autofill.SaveCallback +import android.service.autofill.SaveRequest +import androidx.annotation.RequiresApi +import com.github.ajalt.timberkt.d +import com.github.ajalt.timberkt.e +import com.github.androidpasswordstore.autofillparser.AutofillScenario +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.androidpasswordstore.autofillparser.FillableForm +import com.github.androidpasswordstore.autofillparser.FixedSaveCallback +import com.github.androidpasswordstore.autofillparser.FormOrigin +import com.github.androidpasswordstore.autofillparser.cachePublicSuffixList +import com.github.androidpasswordstore.autofillparser.passwordValue +import com.github.androidpasswordstore.autofillparser.recoverNodes +import com.github.androidpasswordstore.autofillparser.usernameValue +import dev.msfjarvis.aps.BuildConfig +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity +import dev.msfjarvis.aps.util.autofill.Api30AutofillResponseBuilder +import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.hasFlag +import dev.msfjarvis.aps.util.extensions.sharedPrefs + +@RequiresApi(Build.VERSION_CODES.O) +class OreoAutofillService : AutofillService() { + + companion object { + + // TODO: Provide a user-configurable denylist + private val DENYLISTED_PACKAGES = listOf( + BuildConfig.APPLICATION_ID, + "android", + "com.android.settings", + "com.android.settings.intelligence", + "com.android.systemui", + "com.oneplus.applocker", + "org.sufficientlysecure.keychain", + ) + + private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L + } + + override fun onCreate() { + super.onCreate() + cachePublicSuffixList(applicationContext) + } + + override fun onFillRequest( + request: FillRequest, + cancellationSignal: CancellationSignal, + callback: FillCallback + ) { + val structure = request.fillContexts.lastOrNull()?.structure ?: run { + callback.onSuccess(null) + return + } + if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) { + if (Build.VERSION.SDK_INT >= 28) { + callback.onSuccess(FillResponse.Builder().run { + disableAutofill(DISABLE_AUTOFILL_DURATION_MS) + build() + }) + } else { + callback.onSuccess(null) + } + return + } + val formToFill = FillableForm.parseAssistStructure( + this, structure, + isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST, + getCustomSuffixes(), + ) ?: run { + d { "Form cannot be filled" } + callback.onSuccess(null) + return + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback) + } else { + AutofillResponseBuilder(formToFill).fillCredentials(this, callback) + } + } + + override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { + // SaveCallback's behavior and feature set differs based on both target and device SDK, so + // we replace it with a wrapper that works the same in all situations. + @Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback) + val structure = request.fillContexts.lastOrNull()?.structure ?: run { + callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported)) + return + } + val clientState = request.clientState ?: run { + e { "Received save request without client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + val scenario = AutofillScenario.fromClientState(clientState)?.recoverNodes(structure) + ?: run { + e { "Failed to recover client state or nodes from client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + val formOrigin = FormOrigin.fromBundle(clientState) ?: run { + e { "Failed to recover form origin from client state" } + callback.onFailure(getString(R.string.oreo_autofill_save_internal_error)) + return + } + + val username = scenario.usernameValue + val password = scenario.passwordValue ?: run { + callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match)) + return + } + callback.onSuccess( + AutofillSaveActivity.makeSaveIntentSender( + this, + credentials = Credentials(username, password, null), + formOrigin = formOrigin + ) + ) + } +} + +fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) + +fun Context.getCustomSuffixes(): Sequence { + return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) + ?.splitToSequence('\n') + ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' } + ?: emptySequence() +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt new file mode 100644 index 00000000..9164aa46 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt @@ -0,0 +1,158 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.services + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.content.getSystemService +import androidx.documentfile.provider.DocumentFile +import com.github.ajalt.timberkt.d +import dev.msfjarvis.aps.R +import dev.msfjarvis.aps.data.repo.PasswordRepository +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.TimeZone + +class PasswordExportService : Service() { + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent != null) { + when (intent.action) { + ACTION_EXPORT_PASSWORD -> { + val uri = intent.getParcelableExtra("uri") + if (uri != null) { + val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri) + + if (targetDirectory != null) { + createNotification() + exportPasswords(targetDirectory) + stopSelf() + return START_NOT_STICKY + } + } + } + } + } + return super.onStartCommand(intent, flags, startId) + } + + override fun onBind(intent: Intent?): IBinder? { + return null + } + + /** + * Exports passwords to the given directory. + * + * Recursively copies the existing password store to an external directory. + * + * @param targetDirectory directory to copy password directory to. + */ + private fun exportPasswords(targetDirectory: DocumentFile) { + + val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory()) + val sourcePassDir = DocumentFile.fromFile(repositoryDirectory) + + d { "Copying ${repositoryDirectory.path} to $targetDirectory" } + + val dateString = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + LocalDateTime + .now() + .format(DateTimeFormatter.ISO_DATE_TIME) + } else { + String.format("%tFT% + if (file.isDirectory) { + // Create new directory and recurse + val newDir = targetDirectory.createDirectory(file.name!!) + copyDirToDir(file, newDir!!) + } else { + copyFileToDir(file, targetDirectory) + } + } + } + + private fun createNotification() { + createNotificationChannel() + + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.app_name)) + .setContentText(getString(R.string.exporting_passwords)) + .setSmallIcon(R.drawable.ic_round_import_export) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + startForeground(2, notification) + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val serviceChannel = NotificationChannel( + CHANNEL_ID, + getString(R.string.app_name), + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService() + if (manager != null) { + manager.createNotificationChannel(serviceChannel) + } else { + d { "Failed to create notification channel" } + } + } + } + + companion object { + + const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD" + private const val CHANNEL_ID = "NotificationService" + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt new file mode 100644 index 00000000..864cbf81 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt @@ -0,0 +1,181 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.settings + +import androidx.core.content.edit +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.Application +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.extensions.getEncryptedGitPrefs +import dev.msfjarvis.aps.util.extensions.getEncryptedProxyPrefs +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File +import org.eclipse.jgit.transport.URIish + +enum class Protocol(val pref: String) { + Ssh("ssh://"), + Https("https://"), + ; + + companion object { + + private val map = values().associateBy(Protocol::pref) + fun fromString(type: String?): Protocol { + return map[type ?: return Ssh] + ?: throw IllegalArgumentException("$type is not a valid Protocol") + } + } +} + +enum class AuthMode(val pref: String) { + SshKey("ssh-key"), + Password("username/password"), + OpenKeychain("OpenKeychain"), + None("None"), + ; + + companion object { + + private val map = values().associateBy(AuthMode::pref) + fun fromString(type: String?): AuthMode { + return map[type ?: return SshKey] + ?: throw IllegalArgumentException("$type is not a valid AuthMode") + } + } +} + +object GitSettings { + + private const val DEFAULT_BRANCH = "master" + + private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs } + private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedGitPrefs() } + private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() } + + var authMode + get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH)) + private set(value) { + settings.edit { + putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) + } + } + var url + get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL) + private set(value) { + require(value != null) + if (value == url) + return + settings.edit { + putString(PreferenceKeys.GIT_REMOTE_URL, value) + } + if (PasswordRepository.isInitialized) + PasswordRepository.addRemote("origin", value, true) + // When the server changes, remote password, multiplexing support and host key file + // should be deleted/reset. + useMultiplexing = true + encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) } + File("${Application.instance.filesDir}/.host_key").delete() + } + var authorName + get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: "" + set(value) { + settings.edit { + putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) + } + } + var authorEmail + get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: "" + set(value) { + settings.edit { + putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) + } + } + var branch + get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH + private set(value) { + settings.edit { + putString(PreferenceKeys.GIT_BRANCH_NAME, value) + } + } + var useMultiplexing + get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true) + set(value) { + settings.edit { + putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) + } + } + + var proxyHost + get() = proxySettings.getString(PreferenceKeys.PROXY_HOST) + set(value) { + proxySettings.edit { + putString(PreferenceKeys.PROXY_HOST, value) + } + } + + var proxyPort + get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1) + set(value) { + proxySettings.edit { + putInt(PreferenceKeys.PROXY_PORT, value) + } + } + + var proxyUsername + get() = settings.getString(PreferenceKeys.PROXY_USERNAME) + set(value) { + proxySettings.edit { + putString(PreferenceKeys.PROXY_USERNAME, value) + } + } + + var proxyPassword + get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD) + set(value) { + proxySettings.edit { + putString(PreferenceKeys.PROXY_PASSWORD, value) + } + } + + sealed class UpdateConnectionSettingsResult { + class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult() + class AuthModeMismatch(val newProtocol: Protocol, val validModes: List) : UpdateConnectionSettingsResult() + object Valid : UpdateConnectionSettingsResult() + object FailedToParseUrl : UpdateConnectionSettingsResult() + } + + fun updateConnectionSettingsIfValid(newAuthMode: AuthMode, newUrl: String, newBranch: String): UpdateConnectionSettingsResult { + val parsedUrl = runCatching { + URIish(newUrl) + }.getOrElse { + return UpdateConnectionSettingsResult.FailedToParseUrl + } + val newProtocol = when (parsedUrl.scheme) { + in listOf("http", "https") -> Protocol.Https + in listOf("ssh", null) -> Protocol.Ssh + else -> return UpdateConnectionSettingsResult.FailedToParseUrl + } + if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank()) + return UpdateConnectionSettingsResult.MissingUsername(newProtocol) + + val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password) + val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey) + when { + newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> { + return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth) + } + newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> { + return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth) + } + } + + url = newUrl + authMode = newAuthMode + branch = newBranch + return UpdateConnectionSettingsResult.Valid + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt new file mode 100644 index 00000000..fc506995 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt @@ -0,0 +1,114 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +@file:Suppress("DEPRECATION") + +package dev.msfjarvis.aps.util.settings + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import com.github.ajalt.timberkt.e +import com.github.ajalt.timberkt.i +import com.github.michaelbull.result.get +import com.github.michaelbull.result.runCatching +import dev.msfjarvis.aps.util.git.sshj.SshKey +import dev.msfjarvis.aps.util.extensions.getString +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File +import java.net.URI + +fun runMigrations(context: Context) { + val sharedPrefs = context.sharedPrefs + migrateToGitUrlBasedConfig(sharedPrefs) + migrateToHideAll(sharedPrefs) + migrateToSshKey(context, sharedPrefs) +} + +private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) { + val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) + ?: return + i { "Migrating to URL-based Git config" } + val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: "" + val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: "" + val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: "" + val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL)) + + // Whether we need the leading ssh:// depends on the use of a custom port. + val hostnamePart = serverHostname.removePrefix("ssh://") + val url = when (protocol) { + Protocol.Ssh -> { + val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@" + val portPart = + if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort" + if (portPart.isEmpty()) { + "$userPart$hostnamePart:$serverPath" + } else { + // Only absolute paths are supported with custom ports. + if (!serverPath.startsWith('/')) + null + else + // We have to specify the ssh scheme as this is the only way to pass a custom + // port. + "ssh://$userPart$hostnamePart$portPart$serverPath" + } + } + Protocol.Https -> { + val portPart = + if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort" + val pathPart = serverPath.trimStart('/', ':') + val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart" + val url = when { + urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme + urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https") + else -> "https://$urlWithFreeEntryScheme" + } + runCatching { + if (URI(url).rawAuthority != null) + url + else + null + }.get() + } + } + + sharedPrefs.edit { + remove(PreferenceKeys.GIT_REMOTE_LOCATION) + remove(PreferenceKeys.GIT_REMOTE_PORT) + remove(PreferenceKeys.GIT_REMOTE_SERVER) + remove(PreferenceKeys.GIT_REMOTE_USERNAME) + remove(PreferenceKeys.GIT_REMOTE_PROTOCOL) + } + if (url == null || GitSettings.updateConnectionSettingsIfValid( + newAuthMode = GitSettings.authMode, + newUrl = url, + newBranch = GitSettings.branch) != GitSettings.UpdateConnectionSettingsResult.Valid) { + e { "Failed to migrate to URL-based Git config, generated URL is invalid" } + } +} + +private fun migrateToHideAll(sharedPrefs: SharedPreferences) { + sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return + val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) + sharedPrefs.edit { + remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS) + putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden) + } +} + +private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) { + val privateKeyFile = File(context.filesDir, ".ssh_key") + if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && + !SshKey.exists && + privateKeyFile.exists()) { + // Currently uses a private key imported or generated with an old version of Password Store. + // Generated keys come with a public key which the user should still be able to view after + // the migration (not possible for regular imported keys), hence the special case. + val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false) + SshKey.useLegacyKey(isGeneratedKey) + sharedPrefs.edit { + remove(PreferenceKeys.USE_GENERATED_KEY) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt new file mode 100644 index 00000000..f5a639f0 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt @@ -0,0 +1,49 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.settings + +import android.content.Context +import android.content.SharedPreferences +import dev.msfjarvis.aps.Application +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.util.extensions.base64 +import dev.msfjarvis.aps.util.extensions.getString + +enum class PasswordSortOrder(val comparator: java.util.Comparator) { + + FOLDER_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem -> + (p1.type + p1.name) + .compareTo(p2.type + p2.name, ignoreCase = true) + }), + + INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem -> + p1.name.compareTo(p2.name, ignoreCase = true) + }), + + RECENTLY_USED(Comparator { p1: PasswordItem, p2: PasswordItem -> + val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) + val timeP1 = recentHistory.getString(p1.file.absolutePath.base64()) + val timeP2 = recentHistory.getString(p2.file.absolutePath.base64()) + when { + timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1) + timeP1 != null && timeP2 == null -> return@Comparator -1 + timeP1 == null && timeP2 != null -> return@Comparator 1 + else -> p1.name.compareTo(p2.name, ignoreCase = true) + } + }), + + FILE_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem -> + (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true) + }); + + companion object { + + @JvmStatic + fun getSortOrder(settings: SharedPreferences): PasswordSortOrder { + return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name) + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt b/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt new file mode 100644 index 00000000..198be889 --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt @@ -0,0 +1,85 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.settings + +object PreferenceKeys { + + const val APP_THEME = "app_theme" + const val APP_VERSION = "app_version" + const val AUTOFILL_ENABLE = "autofill_enable" + const val BIOMETRIC_AUTH = "biometric_auth" + const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x" + const val CLEAR_SAVED_PASS = "clear_saved_pass" + const val COPY_ON_DECRYPT = "copy_on_decrypt" + const val ENABLE_DEBUG_LOGGING = "enable_debug_logging" + const val EXPORT_PASSWORDS = "export_passwords" + const val FILTER_RECURSIVELY = "filter_recursively" + const val GENERAL_SHOW_TIME = "general_show_time" + const val GIT_CONFIG = "git_config" + const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email" + const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name" + const val GIT_EXTERNAL = "git_external" + const val GIT_EXTERNAL_REPO = "git_external_repo" + const val GIT_REMOTE_AUTH = "git_remote_auth" + const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type" + + @Deprecated("Use GIT_REMOTE_URL instead") + const val GIT_REMOTE_LOCATION = "git_remote_location" + const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing" + + @Deprecated("Use GIT_REMOTE_URL instead") + const val GIT_REMOTE_PORT = "git_remote_port" + + @Deprecated("Use GIT_REMOTE_URL instead") + const val GIT_REMOTE_PROTOCOL = "git_remote_protocol" + const val GIT_DELETE_REPO = "git_delete_repo" + + @Deprecated("Use GIT_REMOTE_URL instead") + const val GIT_REMOTE_SERVER = "git_remote_server" + const val GIT_REMOTE_URL = "git_remote_url" + + @Deprecated("Use GIT_REMOTE_URL instead") + const val GIT_REMOTE_USERNAME = "git_remote_username" + const val GIT_SERVER_INFO = "git_server_info" + const val GIT_BRANCH_NAME = "git_branch" + const val HTTPS_PASSWORD = "https_password" + const val LENGTH = "length" + const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes" + const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username" + const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure" + const val PREF_KEY_CUSTOM_DICT = "pref_key_custom_dict" + const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict" + const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" + const val PREF_SELECT_EXTERNAL = "pref_select_external" + const val REPOSITORY_INITIALIZED = "repository_initialized" + const val REPO_CHANGED = "repo_changed" + const val SEARCH_ON_START = "search_on_start" + const val SHOW_EXTRA_CONTENT = "show_extra_content" + + @Deprecated( + message = "Use SHOW_HIDDEN_CONTENTS instead", + replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS") + ) + const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders" + const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents" + const val SORT_ORDER = "sort_order" + const val SHOW_PASSWORD = "show_password" + const val SSH_KEY = "ssh_key" + const val SSH_KEYGEN = "ssh_keygen" + const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase" + const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid" + const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid" + const val SSH_SEE_KEY = "ssh_see_key" + + @Deprecated("To be used only in Migrations.kt") + const val USE_GENERATED_KEY = "use_generated_key" + + const val PROXY_SETTINGS = "proxy_settings" + const val PROXY_HOST = "proxy_host" + const val PROXY_PORT = "proxy_port" + const val PROXY_USERNAME = "proxy_username" + const val PROXY_PASSWORD = "proxy_password" +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt new file mode 100644 index 00000000..551b2e5d --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt @@ -0,0 +1,73 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.totp + +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.runCatching +import java.nio.ByteBuffer +import java.util.Locale +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec +import kotlin.experimental.and +import org.apache.commons.codec.binary.Base32 + +object Otp { + + private val BASE_32 = Base32() + private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray() + + init { + check(STEAM_ALPHABET.size == 26) + } + + fun calculateCode(secret: String, counter: Long, algorithm: String, digits: String) = runCatching { + val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}" + val decodedSecret = BASE_32.decode(secret) + val secretKey = SecretKeySpec(decodedSecret, algo) + val digest = Mac.getInstance(algo).run { + init(secretKey) + doFinal(ByteBuffer.allocate(8).putLong(counter).array()) + } + // Least significant 4 bits are used as an offset into the digest. + val offset = (digest.last() and 0xf).toInt() + // Extract 32 bits at the offset and clear the most significant bit. + val code = digest.copyOfRange(offset, offset + 4) + code[0] = (0x7f and code[0].toInt()).toByte() + val codeInt = ByteBuffer.wrap(code).int + check(codeInt > 0) + if (digits == "s") { + // Steam + var remainingCodeInt = codeInt + buildString { + repeat(5) { + append(STEAM_ALPHABET[remainingCodeInt % 26]) + remainingCodeInt /= 26 + } + } + } else { + // Base 10, 6 to 10 digits + val numDigits = digits.toIntOrNull() + when { + numDigits == null -> { + return Err(IllegalArgumentException("Digits specifier has to be either 's' or numeric")) + } + numDigits < 6 -> { + return Err(IllegalArgumentException("TOTP codes have to be at least 6 digits long")) + } + numDigits > 10 -> { + return Err(IllegalArgumentException("TOTP codes can be at most 10 digits long")) + } + else -> { + // 2^31 = 2_147_483_648, so we can extract at most 10 digits with the first one + // always being 0, 1, or 2. Pad with leading zeroes. + val codeStringBase10 = codeInt.toString(10).padStart(10, '0') + check(codeStringBase10.length == 10) + codeStringBase10.takeLast(numDigits) + } + } + } + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt new file mode 100644 index 00000000..fb43980c --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt @@ -0,0 +1,32 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.totp + +/** + * Defines a class that can extract relevant parts of a TOTP URL for use by the app. + */ +interface TotpFinder { + + /** + * Get the TOTP secret from the given extra content. + */ + fun findSecret(content: String): String? + + /** + * Get the number of digits required in the final OTP. + */ + fun findDigits(content: String): String + + /** + * Get the TOTP timeout period. + */ + fun findPeriod(content: String): Long + + /** + * Get the algorithm for the TOTP secret. + */ + fun findAlgorithm(content: String): String +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt new file mode 100644 index 00000000..21910a3a --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt @@ -0,0 +1,66 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package dev.msfjarvis.aps.util.totp + +import android.net.Uri + +/** + * [Uri] backed TOTP URL parser. + */ +class UriTotpFinder : TotpFinder { + + override fun findSecret(content: String): String? { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0])) { + return Uri.parse(line).getQueryParameter("secret") + } + if (line.startsWith(TOTP_FIELDS[1], ignoreCase = true)) { + return line.split(": *".toRegex(), 2).toTypedArray()[1] + } + } + return null + } + + override fun findDigits(content: String): String { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0]) && + Uri.parse(line).getQueryParameter("digits") != null) { + return Uri.parse(line).getQueryParameter("digits")!! + } + } + return "6" + } + + override fun findPeriod(content: String): Long { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0]) && + Uri.parse(line).getQueryParameter("period") != null) { + val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull() + if (period != null && period > 0) + return period + } + } + return 30 + } + + override fun findAlgorithm(content: String): String { + content.split("\n".toRegex()).forEach { line -> + if (line.startsWith(TOTP_FIELDS[0]) && + Uri.parse(line).getQueryParameter("algorithm") != null) { + return Uri.parse(line).getQueryParameter("algorithm")!! + } + } + return "sha1" + } + + companion object { + + val TOTP_FIELDS = arrayOf( + "otpauth://totp", + "totp:" + ) + } +} diff --git a/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt new file mode 100644 index 00000000..6cd47b4b --- /dev/null +++ b/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt @@ -0,0 +1,474 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.util.viewmodel + +import android.app.Application +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asFlow +import androidx.lifecycle.asLiveData +import androidx.recyclerview.selection.ItemDetailsLookup +import androidx.recyclerview.selection.ItemKeyProvider +import androidx.recyclerview.selection.Selection +import androidx.recyclerview.selection.SelectionPredicates +import androidx.recyclerview.selection.SelectionTracker +import androidx.recyclerview.selection.StorageStrategy +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import dev.msfjarvis.aps.util.autofill.AutofillPreferences +import dev.msfjarvis.aps.util.autofill.DirectoryStructure +import dev.msfjarvis.aps.data.password.PasswordItem +import dev.msfjarvis.aps.data.repo.PasswordRepository +import dev.msfjarvis.aps.util.settings.PasswordSortOrder +import dev.msfjarvis.aps.util.settings.PreferenceKeys +import dev.msfjarvis.aps.util.extensions.sharedPrefs +import java.io.File +import java.text.Collator +import java.util.Locale +import java.util.Stack +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.yield +import me.zhanghai.android.fastscroll.PopupTextProvider + +private fun File.toPasswordItem() = if (isFile) + PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory()) +else + PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory()) + +private fun PasswordItem.fuzzyMatch(filter: String): Int { + var i = 0 + var j = 0 + var score = 0 + var bonus = 0 + var bonusIncrement = 0 + + val toMatch = longName + + while (i < filter.length && j < toMatch.length) { + when { + filter[i].isWhitespace() -> i++ + filter[i].toLowerCase() == toMatch[j].toLowerCase() -> { + i++ + bonusIncrement += 1 + bonus += bonusIncrement + score += bonus + } + else -> { + bonus = 0 + bonusIncrement = 0 + } + } + j++ + } + return if (i == filter.length) score else 0 +} + +private val CaseInsensitiveComparator = Collator.getInstance().apply { + strength = Collator.PRIMARY +} + +private fun PasswordItem.Companion.makeComparator( + typeSortOrder: PasswordSortOrder, + directoryStructure: DirectoryStructure +): Comparator { + return when (typeSortOrder) { + PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } + // In order to let INDEPENDENT not distinguish between items based on their type, we simply + // declare them all equal at this stage. + PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 } + PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } + PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator + } + .then(compareBy(nullsLast(CaseInsensitiveComparator)) { + directoryStructure.getIdentifierFor(it.file) + }) + .then(compareBy(nullsLast(CaseInsensitiveComparator)) { + directoryStructure.getUsernameFor(it.file) + }) +} + +val PasswordItem.stableId: String + get() = file.absolutePath + +enum class FilterMode { + NoFilter, + StrictDomain, + Fuzzy +} + +enum class SearchMode { + RecursivelyInSubdirectories, + InCurrentDirectoryOnly +} + +enum class ListMode { + FilesOnly, + DirectoriesOnly, + AllEntries +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +class SearchableRepositoryViewModel(application: Application) : AndroidViewModel(application) { + + private var _updateCounter = 0 + private val updateCounter: Int + get() = _updateCounter + + private fun forceUpdateOnNextSearchAction() { + _updateCounter++ + } + + private val root + get() = PasswordRepository.getRepositoryDirectory() + private val settings by lazy(LazyThreadSafetyMode.NONE) { application.sharedPrefs } + private val showHiddenContents + get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) + private val defaultSearchMode + get() = if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) { + SearchMode.RecursivelyInSubdirectories + } else { + SearchMode.InCurrentDirectoryOnly + } + + private val typeSortOrder + get() = PasswordSortOrder.getSortOrder(settings) + private val directoryStructure + get() = AutofillPreferences.directoryStructure(getApplication()) + private val itemComparator + get() = PasswordItem.makeComparator(typeSortOrder, directoryStructure) + + private data class SearchAction( + val baseDirectory: File, + val filter: String, + val filterMode: FilterMode, + val searchMode: SearchMode, + val listMode: ListMode, + // This counter can be increased to force a reexecution of the search action even if all + // other arguments are left unchanged. + val updateCounter: Int + ) + + private fun makeSearchAction( + baseDirectory: File, + filter: String, + filterMode: FilterMode, + searchMode: SearchMode, + listMode: ListMode + ): SearchAction { + return SearchAction( + baseDirectory = baseDirectory, + filter = filter, + filterMode = filterMode, + searchMode = searchMode, + listMode = listMode, + updateCounter = updateCounter + ) + } + + private fun updateSearchAction(action: SearchAction) = + action.copy(updateCounter = updateCounter) + + private val searchAction = MutableLiveData( + makeSearchAction( + baseDirectory = root, + filter = "", + filterMode = FilterMode.NoFilter, + searchMode = SearchMode.InCurrentDirectoryOnly, + listMode = ListMode.AllEntries + ) + ) + private val searchActionFlow = searchAction.asFlow().distinctUntilChanged() + + data class SearchResult(val passwordItems: List, val isFiltered: Boolean) + + val searchResult = searchActionFlow + .mapLatest { searchAction -> + val listResultFlow = when (searchAction.searchMode) { + SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory) + SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory) + } + val prefilteredResultFlow = when (searchAction.listMode) { + ListMode.FilesOnly -> listResultFlow.filter { it.isFile } + ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory } + ListMode.AllEntries -> listResultFlow + } + val filterModeToUse = + if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode + val passwordList = when (filterModeToUse) { + FilterMode.NoFilter -> { + prefilteredResultFlow + .map { it.toPasswordItem() } + .toList() + .sortedWith(itemComparator) + } + FilterMode.StrictDomain -> { + check(searchAction.listMode == ListMode.FilesOnly) { "Searches with StrictDomain search mode can only list files" } + val regex = generateStrictDomainRegex(searchAction.filter) + if (regex != null) { + prefilteredResultFlow + .filter { absoluteFile -> + regex.containsMatchIn(absoluteFile.relativeTo(root).path) + } + .map { it.toPasswordItem() } + .toList() + .sortedWith(itemComparator) + } else { + emptyList() + } + } + FilterMode.Fuzzy -> { + prefilteredResultFlow + .map { + val item = it.toPasswordItem() + Pair(item.fuzzyMatch(searchAction.filter), item) + } + .filter { it.first > 0 } + .toList() + .sortedWith( + compareByDescending> { it.first }.thenBy( + itemComparator + ) { it.second }) + .map { it.second } + } + } + SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter) + }.asLiveData(Dispatchers.IO) + + private fun shouldTake(file: File) = with(file) { + if (showHiddenContents) return true + if (isDirectory) { + !isHidden + } else { + !isHidden && file.extension == "gpg" + } + } + + private fun listFiles(dir: File): Flow { + return dir.listFiles { file -> shouldTake(file) }?.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 { file -> shouldTake(file) } + } + + 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 viewHolderBinder: 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 { + viewHolderBinder.invoke(this, item) + selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) } + itemView.setOnClickListener { + // Do not emit custom click events while the user is selecting items. + if (selectionTracker?.hasSelection() != true) + onItemClickedListener?.invoke(holder, item) + } + } + } + + final override fun getPopupText(position: Int): String { + return getItem(position).name[0].toString().toUpperCase(Locale.getDefault()) + } +} diff --git a/app/src/main/res/layout/activity_git_clone.xml b/app/src/main/res/layout/activity_git_clone.xml index ad0a186b..1e59b462 100644 --- a/app/src/main/res/layout/activity_git_clone.xml +++ b/app/src/main/res/layout/activity_git_clone.xml @@ -11,7 +11,7 @@ android:background="?android:attr/windowBackground" android:padding="@dimen/activity_horizontal_margin" tools:background="@color/white" - tools:context="com.zeapo.pwdstore.git.GitServerConfigActivity"> + tools:context="dev.msfjarvis.aps.git.GitServerConfigActivity"> diff --git a/app/src/main/res/layout/activity_git_log.xml b/app/src/main/res/layout/activity_git_log.xml index 96b28487..22810d33 100644 --- a/app/src/main/res/layout/activity_git_log.xml +++ b/app/src/main/res/layout/activity_git_log.xml @@ -16,7 +16,7 @@ android:orientation="vertical" android:scrollbars="vertical" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - tools:context="com.zeapo.pwdstore.git.log.GitLogActivity" + tools:context=".ui.git.log.GitLogActivity" tools:listitem="@layout/git_log_row_layout" /> diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml index 6d880c31..76b96638 100644 --- a/app/src/main/res/layout/activity_onboarding.xml +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -6,7 +6,7 @@ diff --git a/app/src/main/res/layout/activity_oreo_autofill_filter.xml b/app/src/main/res/layout/activity_oreo_autofill_filter.xml index 8e671e30..44c768e1 100644 --- a/app/src/main/res/layout/activity_oreo_autofill_filter.xml +++ b/app/src/main/res/layout/activity_oreo_autofill_filter.xml @@ -9,7 +9,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:elevation="2dp" - tools:context="com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView"> + tools:context="dev.msfjarvis.aps.ui.autofill.AutofillFilterView"> + tools:context="dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity"> + tools:context=".ui.crypto.DecryptActivity"> + tools:context=".ui.crypto.PasswordCreationActivity"> + tools:context="dev.msfjarvis.aps.ui.passwords.PasswordFragment"> + tools:context=".ui.crypto.PgpActivity"> + tools:context=".ui.crypto.PgpActivity"> + tools:context="dev.msfjarvis.aps.SelectFolderActivity"> Task.suspendableAwait() = suspendCoroutine { cont -> - addOnSuccessListener { result: T -> - cont.resume(result) - } - addOnFailureListener { e -> - // Unwrap specific exceptions (e.g. ResolvableApiException) from ExecutionException. - val cause = (e as? ExecutionException)?.cause ?: e - cont.resumeWithException(cause) - } -} - -@RequiresApi(Build.VERSION_CODES.O) -class AutofillSmsActivity : AppCompatActivity() { - - companion object { - - private var fillOtpFromSmsRequestCode = 1 - - fun shouldOfferFillFromSms(context: Context): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) - return false - val googleApiAvailabilityInstance = GoogleApiAvailability.getInstance() - val googleApiStatus = googleApiAvailabilityInstance.isGooglePlayServicesAvailable(context) - if (googleApiStatus != ConnectionResult.SUCCESS) { - w { "Google Play Services unavailable or not updated: ${googleApiAvailabilityInstance.getErrorString(googleApiStatus)}" } - return false - } - // https://developer.android.com/guide/topics/text/autofill-services#sms-autofill - if (googleApiAvailabilityInstance.getApkVersion(context) < 190056000) { - w { "Google Play Service 19.0.56 or higher required for SMS OTP Autofill" } - return false - } - return true - } - - fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender { - val intent = Intent(context, AutofillSmsActivity::class.java) - return PendingIntent.getActivity( - context, - fillOtpFromSmsRequestCode++, - intent, - PendingIntent.FLAG_CANCEL_CURRENT - ).intentSender - } - } - - private val binding by viewBinding(ActivityOreoAutofillSmsBinding::inflate) - - private lateinit var clientState: Bundle - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(binding.root) - setResult(RESULT_CANCELED) - binding.cancelButton.setOnClickListener { - finish() - } - } - - override fun onStart() { - super.onStart() - clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { - e { "AutofillSmsActivity started without EXTRA_CLIENT_STATE" } - finish() - return - } - - registerReceiver(smsCodeRetrievedReceiver, IntentFilter(SmsCodeRetriever.SMS_CODE_RETRIEVED_ACTION), SmsRetriever.SEND_PERMISSION, null) - lifecycleScope.launch { - waitForSms() - } - } - - // Retry starting the SMS code retriever after a permission request. - @Suppress("DEPRECATION") - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (resultCode != Activity.RESULT_OK) - return - lifecycleScope.launch { - waitForSms() - } - } - - private suspend fun waitForSms() { - val smsClient = SmsCodeRetriever.getAutofillClient(this@AutofillSmsActivity) - runCatching { - withContext(Dispatchers.IO) { - smsClient.startSmsCodeRetriever().suspendableAwait() - } - }.onFailure { e -> - if (e is ResolvableApiException) { - e.startResolutionForResult(this@AutofillSmsActivity, 1) - } else { - e(e) - withContext(Dispatchers.Main) { - finish() - } - } - } - } - - private val smsCodeRetrievedReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val smsCode = intent.getStringExtra(SmsCodeRetriever.EXTRA_SMS_CODE) - val fillInDataset = AutofillResponseBuilder.makeFillInDataset( - this@AutofillSmsActivity, - Credentials(null, null, smsCode), - clientState, - AutofillAction.FillOtpFromSms - ) - setResult(RESULT_OK, Intent().apply { - putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) - }) - finish() - } - } -} diff --git a/app/src/nonFree/java/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt b/app/src/nonFree/java/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt new file mode 100644 index 00000000..f223cc79 --- /dev/null +++ b/app/src/nonFree/java/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt @@ -0,0 +1,160 @@ +/* + * Copyright © 2014-2020 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +package dev.msfjarvis.aps.autofill.oreo.ui + +import android.app.Activity +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +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.ajalt.timberkt.e +import com.github.ajalt.timberkt.w +import com.github.androidpasswordstore.autofillparser.AutofillAction +import com.github.androidpasswordstore.autofillparser.Credentials +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.runCatching +import com.google.android.gms.auth.api.phone.SmsCodeRetriever +import com.google.android.gms.auth.api.phone.SmsRetriever +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.gms.common.api.ResolvableApiException +import com.google.android.gms.tasks.Task +import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder +import dev.msfjarvis.aps.databinding.ActivityOreoAutofillSmsBinding +import dev.msfjarvis.aps.util.extensions.viewBinding +import java.util.concurrent.ExecutionException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +suspend fun Task.suspendableAwait() = suspendCoroutine { cont -> + addOnSuccessListener { result: T -> + cont.resume(result) + } + addOnFailureListener { e -> + // Unwrap specific exceptions (e.g. ResolvableApiException) from ExecutionException. + val cause = (e as? ExecutionException)?.cause ?: e + cont.resumeWithException(cause) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +class AutofillSmsActivity : AppCompatActivity() { + + companion object { + + private var fillOtpFromSmsRequestCode = 1 + + fun shouldOfferFillFromSms(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) + return false + val googleApiAvailabilityInstance = GoogleApiAvailability.getInstance() + val googleApiStatus = googleApiAvailabilityInstance.isGooglePlayServicesAvailable(context) + if (googleApiStatus != ConnectionResult.SUCCESS) { + w { "Google Play Services unavailable or not updated: ${googleApiAvailabilityInstance.getErrorString(googleApiStatus)}" } + return false + } + // https://developer.android.com/guide/topics/text/autofill-services#sms-autofill + if (googleApiAvailabilityInstance.getApkVersion(context) < 190056000) { + w { "Google Play Service 19.0.56 or higher required for SMS OTP Autofill" } + return false + } + return true + } + + fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender { + val intent = Intent(context, AutofillSmsActivity::class.java) + return PendingIntent.getActivity( + context, + fillOtpFromSmsRequestCode++, + intent, + PendingIntent.FLAG_CANCEL_CURRENT + ).intentSender + } + } + + private val binding by viewBinding(ActivityOreoAutofillSmsBinding::inflate) + + private lateinit var clientState: Bundle + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + setResult(RESULT_CANCELED) + binding.cancelButton.setOnClickListener { + finish() + } + } + + override fun onStart() { + super.onStart() + clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { + e { "AutofillSmsActivity started without EXTRA_CLIENT_STATE" } + finish() + return + } + + registerReceiver(smsCodeRetrievedReceiver, IntentFilter(SmsCodeRetriever.SMS_CODE_RETRIEVED_ACTION), SmsRetriever.SEND_PERMISSION, null) + lifecycleScope.launch { + waitForSms() + } + } + + // Retry starting the SMS code retriever after a permission request. + @Suppress("DEPRECATION") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode != Activity.RESULT_OK) + return + lifecycleScope.launch { + waitForSms() + } + } + + private suspend fun waitForSms() { + val smsClient = SmsCodeRetriever.getAutofillClient(this@AutofillSmsActivity) + runCatching { + withContext(Dispatchers.IO) { + smsClient.startSmsCodeRetriever().suspendableAwait() + } + }.onFailure { e -> + if (e is ResolvableApiException) { + e.startResolutionForResult(this@AutofillSmsActivity, 1) + } else { + e(e) + withContext(Dispatchers.Main) { + finish() + } + } + } + } + + private val smsCodeRetrievedReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val smsCode = intent.getStringExtra(SmsCodeRetriever.EXTRA_SMS_CODE) + val fillInDataset = AutofillResponseBuilder.makeFillInDataset( + this@AutofillSmsActivity, + Credentials(null, null, smsCode), + clientState, + AutofillAction.FillOtpFromSms + ) + setResult(RESULT_OK, Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) + }) + finish() + } + } +} diff --git a/app/src/nonFree/res/layout/activity_oreo_autofill_sms.xml b/app/src/nonFree/res/layout/activity_oreo_autofill_sms.xml index 294944d1..03b88fe4 100644 --- a/app/src/nonFree/res/layout/activity_oreo_autofill_sms.xml +++ b/app/src/nonFree/res/layout/activity_oreo_autofill_sms.xml @@ -9,7 +9,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:paddingHorizontal="10dp" - tools:context="com.zeapo.pwdstore.autofill.oreo.ui.AutofillFilterView"> + tools:context="dev.msfjarvis.aps.ui.autofill.AutofillFilterView">