aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.idea/codeStyles/Project.xml6
-rw-r--r--app/build.gradle.kts160
-rw-r--r--app/src/androidTest/java/dev/msfjarvis/aps/data/password/PasswordEntryAndroidTest.kt175
-rw-r--r--app/src/androidTest/java/dev/msfjarvis/aps/util/settings/MigrationsTest.kt173
-rw-r--r--app/src/androidTest/java/dev/msfjarvis/aps/util/totp/UriTotpFinderTest.kt68
-rw-r--r--app/src/androidTest/java/dev/msfjarvis/aps/util/viewmodel/StrictDomainRegexTest.kt72
-rw-r--r--app/src/free/java/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt14
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/Application.kt66
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt37
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt318
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt99
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt368
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt112
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt101
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt319
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt310
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt135
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt197
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt4
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt442
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt354
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt86
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt813
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt228
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt129
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt85
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt50
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt120
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt183
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt65
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt77
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/base/BaseGitActivity.kt222
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitConfigActivity.kt207
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/config/GitServerConfigActivity.kt401
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogActivity.kt42
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/git/log/GitLogAdapter.kt51
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt72
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/activity/OnboardingActivity.kt20
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/CloneFragment.kt47
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/KeySelectionFragment.kt50
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/RepoLocationFragment.kt268
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/onboarding/fragments/WelcomeFragment.kt12
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt518
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt1159
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt68
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt164
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/DirectorySelectionActivity.kt63
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt140
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt87
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt153
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt309
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt130
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt10
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt39
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt215
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt70
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt100
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt106
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt301
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt286
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt205
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt324
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt108
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt53
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt167
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt65
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt40
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt66
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt71
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt159
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt52
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/BreakOutOfDetached.kt69
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CloneOperation.kt5
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt133
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/GitOperation.kt293
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PullOperation.kt41
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt5
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt23
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/operation/SyncOperation.kt27
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt27
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainKeyProvider.kt286
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt104
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshKey.kt480
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjConfig.kt434
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt235
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt84
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt204
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt20
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt65
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt304
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt6
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt219
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt48
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt279
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt185
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt239
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt317
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt166
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt59
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt138
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt99
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt28
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt87
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt729
-rw-r--r--app/src/nonFree/java/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt183
-rw-r--r--app/src/test/java/dev/msfjarvis/aps/data/password/PasswordEntryTest.kt326
-rw-r--r--app/src/test/java/dev/msfjarvis/aps/util/crypto/GpgIdentifierTest.kt52
-rw-r--r--app/src/test/java/dev/msfjarvis/aps/util/totp/OtpTest.kt97
-rw-r--r--autofill-parser/build.gradle.kts38
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt307
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt120
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt431
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt334
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt643
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt180
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt562
-rw-r--r--autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt74
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt74
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt238
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt40
-rw-r--r--autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt181
-rw-r--r--build.gradle.kts24
-rw-r--r--buildSrc/build.gradle.kts60
-rw-r--r--buildSrc/buildSrc/build.gradle.kts8
-rw-r--r--buildSrc/src/main/java/BaseProjectConfig.kt175
-rw-r--r--buildSrc/src/main/java/BinaryCompatibilityValidator.kt6
-rw-r--r--buildSrc/src/main/java/CrowdinDownloadPlugin.kt104
-rw-r--r--buildSrc/src/main/java/CrowdinExtension.kt18
-rw-r--r--buildSrc/src/main/java/Dependencies.kt154
-rw-r--r--buildSrc/src/main/java/KotlinCompilerArgs.kt4
-rw-r--r--buildSrc/src/main/java/Ktfmt.kt4
-rw-r--r--buildSrc/src/main/java/PasswordStorePlugin.kt57
-rw-r--r--buildSrc/src/main/java/SigningConfig.kt36
-rw-r--r--buildSrc/src/main/java/VersioningPlugin.kt159
-rw-r--r--openpgp-ktx/build.gradle.kts28
-rw-r--r--openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/AutocryptPeerUpdate.kt174
-rw-r--r--openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpApi.kt693
-rw-r--r--openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpServiceConnection.kt118
-rw-r--r--openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpUtils.kt165
-rw-r--r--openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/ParcelFileDescriptorUtil.kt83
-rw-r--r--openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpDecryptionResult.kt208
-rw-r--r--openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpError.kt130
-rw-r--r--openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpMetadata.kt187
-rw-r--r--openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpSignatureResult.kt511
-rw-r--r--settings.gradle.kts10
145 files changed, 11973 insertions, 12447 deletions
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index c21a1678..236614aa 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -4,7 +4,7 @@
<option name="LINE_SEPARATOR" value="&#10;" />
<option name="WRAP_WHEN_TYPING_REACHES_RIGHT_MARGIN" value="true" />
<option name="FORMATTER_TAGS_ENABLED" value="true" />
- <option name="SOFT_MARGINS" value="100" />
+ <option name="SOFT_MARGINS" value="120" />
<option name="DO_NOT_FORMAT">
<list>
<fileSet type="namedScope" name="third_party" pattern="src[Android-Password-Store.app]:mozilla.components.lib.publicsuffixlist..*" />
@@ -161,7 +161,7 @@
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
- <option name="RIGHT_MARGIN" value="100" />
+ <option name="RIGHT_MARGIN" value="120" />
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="0" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
@@ -183,4 +183,4 @@
</indentOptions>
</codeStyleSettings>
</code_scheme>
-</component> \ No newline at end of file
+</component>
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b9b12dc0..2b271563 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -5,104 +5,100 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
plugins {
- id("com.android.application")
- kotlin("android")
- `versioning-plugin`
- `aps-plugin`
- `crowdin-plugin`
+ id("com.android.application")
+ kotlin("android")
+ `versioning-plugin`
+ `aps-plugin`
+ `crowdin-plugin`
}
-configure<CrowdinExtension> {
- projectName = "android-password-store"
-}
+configure<CrowdinExtension> { projectName = "android-password-store" }
android {
- if (isSnapshot()) {
- applicationVariants.all {
- outputs.all {
- (this as BaseVariantOutputImpl).outputFileName = "aps-${flavorName}_$versionName.apk"
- }
- }
+ if (isSnapshot()) {
+ applicationVariants.all {
+ outputs.all {
+ (this as BaseVariantOutputImpl).outputFileName = "aps-${flavorName}_$versionName.apk"
+ }
}
+ }
- defaultConfig {
- applicationId = "dev.msfjarvis.aps"
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- }
+ defaultConfig {
+ applicationId = "dev.msfjarvis.aps"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
- lintOptions {
- isAbortOnError = true
- isCheckReleaseBuilds = false
- disable("MissingTranslation", "PluralsCandidate", "ImpliedQuantity")
- }
+ lintOptions {
+ isAbortOnError = true
+ isCheckReleaseBuilds = false
+ disable("MissingTranslation", "PluralsCandidate", "ImpliedQuantity")
+ }
- flavorDimensions("free")
- productFlavors {
- create("free") {
- }
- create("nonFree") {
- }
- }
+ flavorDimensions("free")
+ productFlavors {
+ create("free") {}
+ create("nonFree") {}
+ }
}
dependencies {
- compileOnly(Dependencies.AndroidX.annotation)
- implementation(project(":autofill-parser"))
- implementation(project(":openpgp-ktx"))
- implementation(Dependencies.AndroidX.activity_ktx)
- implementation(Dependencies.AndroidX.appcompat)
- implementation(Dependencies.AndroidX.autofill)
- implementation(Dependencies.AndroidX.biometric_ktx)
- implementation(Dependencies.AndroidX.constraint_layout)
- implementation(Dependencies.AndroidX.core_ktx)
- implementation(Dependencies.AndroidX.documentfile)
- implementation(Dependencies.AndroidX.fragment_ktx)
- implementation(Dependencies.AndroidX.lifecycle_common)
- implementation(Dependencies.AndroidX.lifecycle_livedata_ktx)
- implementation(Dependencies.AndroidX.lifecycle_viewmodel_ktx)
- implementation(Dependencies.AndroidX.material)
- implementation(Dependencies.AndroidX.preference)
- implementation(Dependencies.AndroidX.recycler_view)
- implementation(Dependencies.AndroidX.recycler_view_selection)
- implementation(Dependencies.AndroidX.security)
- implementation(Dependencies.AndroidX.swiperefreshlayout)
+ compileOnly(Dependencies.AndroidX.annotation)
+ implementation(project(":autofill-parser"))
+ implementation(project(":openpgp-ktx"))
+ implementation(Dependencies.AndroidX.activity_ktx)
+ implementation(Dependencies.AndroidX.appcompat)
+ implementation(Dependencies.AndroidX.autofill)
+ implementation(Dependencies.AndroidX.biometric_ktx)
+ implementation(Dependencies.AndroidX.constraint_layout)
+ implementation(Dependencies.AndroidX.core_ktx)
+ implementation(Dependencies.AndroidX.documentfile)
+ implementation(Dependencies.AndroidX.fragment_ktx)
+ implementation(Dependencies.AndroidX.lifecycle_common)
+ implementation(Dependencies.AndroidX.lifecycle_livedata_ktx)
+ implementation(Dependencies.AndroidX.lifecycle_viewmodel_ktx)
+ implementation(Dependencies.AndroidX.material)
+ implementation(Dependencies.AndroidX.preference)
+ implementation(Dependencies.AndroidX.recycler_view)
+ implementation(Dependencies.AndroidX.recycler_view_selection)
+ implementation(Dependencies.AndroidX.security)
+ implementation(Dependencies.AndroidX.swiperefreshlayout)
- implementation(Dependencies.Kotlin.Coroutines.android)
- implementation(Dependencies.Kotlin.Coroutines.core)
+ implementation(Dependencies.Kotlin.Coroutines.android)
+ implementation(Dependencies.Kotlin.Coroutines.core)
- implementation(Dependencies.FirstParty.zxing_android_embedded)
+ implementation(Dependencies.FirstParty.zxing_android_embedded)
- implementation(Dependencies.ThirdParty.bouncycastle)
- implementation(Dependencies.ThirdParty.commons_codec)
- implementation(Dependencies.ThirdParty.eddsa)
- implementation(Dependencies.ThirdParty.fastscroll)
- implementation(Dependencies.ThirdParty.jgit) {
- exclude(group = "org.apache.httpcomponents", module = "httpclient")
- }
- implementation(Dependencies.ThirdParty.kotlin_result)
- implementation(Dependencies.ThirdParty.modern_android_prefs)
- implementation(Dependencies.ThirdParty.plumber)
- implementation(Dependencies.ThirdParty.ssh_auth)
- implementation(Dependencies.ThirdParty.sshj)
- implementation(Dependencies.ThirdParty.timber)
- implementation(Dependencies.ThirdParty.timberkt)
+ implementation(Dependencies.ThirdParty.bouncycastle)
+ implementation(Dependencies.ThirdParty.commons_codec)
+ implementation(Dependencies.ThirdParty.eddsa)
+ implementation(Dependencies.ThirdParty.fastscroll)
+ implementation(Dependencies.ThirdParty.jgit) {
+ exclude(group = "org.apache.httpcomponents", module = "httpclient")
+ }
+ implementation(Dependencies.ThirdParty.kotlin_result)
+ implementation(Dependencies.ThirdParty.modern_android_prefs)
+ implementation(Dependencies.ThirdParty.plumber)
+ implementation(Dependencies.ThirdParty.ssh_auth)
+ implementation(Dependencies.ThirdParty.sshj)
+ implementation(Dependencies.ThirdParty.timber)
+ implementation(Dependencies.ThirdParty.timberkt)
- if (isSnapshot()) {
- implementation(Dependencies.ThirdParty.leakcanary)
- implementation(Dependencies.ThirdParty.whatthestack)
- } else {
- debugImplementation(Dependencies.ThirdParty.leakcanary)
- debugImplementation(Dependencies.ThirdParty.whatthestack)
- }
+ if (isSnapshot()) {
+ implementation(Dependencies.ThirdParty.leakcanary)
+ implementation(Dependencies.ThirdParty.whatthestack)
+ } else {
+ debugImplementation(Dependencies.ThirdParty.leakcanary)
+ debugImplementation(Dependencies.ThirdParty.whatthestack)
+ }
- "nonFreeImplementation"(Dependencies.NonFree.google_play_auth_api_phone)
+ "nonFreeImplementation"(Dependencies.NonFree.google_play_auth_api_phone)
- // Testing-only dependencies
- androidTestImplementation(Dependencies.Testing.junit)
- androidTestImplementation(Dependencies.Testing.kotlin_test_junit)
- androidTestImplementation(Dependencies.Testing.AndroidX.runner)
- androidTestImplementation(Dependencies.Testing.AndroidX.rules)
+ // Testing-only dependencies
+ androidTestImplementation(Dependencies.Testing.junit)
+ androidTestImplementation(Dependencies.Testing.kotlin_test_junit)
+ androidTestImplementation(Dependencies.Testing.AndroidX.runner)
+ androidTestImplementation(Dependencies.Testing.AndroidX.rules)
- testImplementation(Dependencies.Testing.junit)
- testImplementation(Dependencies.Testing.kotlin_test_junit)
+ testImplementation(Dependencies.Testing.junit)
+ testImplementation(Dependencies.Testing.kotlin_test_junit)
}
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
index 80f178cc..97f3539c 100644
--- a/app/src/androidTest/java/dev/msfjarvis/aps/data/password/PasswordEntryAndroidTest.kt
+++ b/app/src/androidTest/java/dev/msfjarvis/aps/data/password/PasswordEntryAndroidTest.kt
@@ -18,98 +18,105 @@ import org.junit.Test
class PasswordEntryAndroidTest {
- private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder())
+ 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 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 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 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 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 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 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())
- }
+ @Test
+ fun testOnlyLooksForUriInFirstLine() {
+ val entry = makeEntry("id:\n$TOTP_URI")
+ assertTrue(entry.password.isNotEmpty())
+ assertTrue(entry.hasTotp())
+ assertFalse(entry.hasUsername())
+ }
- companion object {
+ companion object {
- const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
- }
+ 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
index cac5d80d..83a6afde 100644
--- a/app/src/androidTest/java/dev/msfjarvis/aps/util/settings/MigrationsTest.kt
+++ b/app/src/androidTest/java/dev/msfjarvis/aps/util/settings/MigrationsTest.kt
@@ -19,102 +19,103 @@ 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))
+ 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 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 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 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 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))
+ @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))
+ }
- @Test
- fun verifyClearClipboardHistoryMigration() {
- val context = Application.instance.applicationContext
- context.sharedPrefs.edit {
- clear()
- putBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, true)
- }
- runMigrations(context)
- assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false))
- assertFalse(context.sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X))
+ @Test
+ fun verifyClearClipboardHistoryMigration() {
+ val context = Application.instance.applicationContext
+ context.sharedPrefs.edit {
+ clear()
+ putBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, true)
}
+ runMigrations(context)
+ assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false))
+ assertFalse(context.sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X))
+ }
}
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
index 4ecc31d2..b89cf0ef 100644
--- a/app/src/androidTest/java/dev/msfjarvis/aps/util/totp/UriTotpFinderTest.kt
+++ b/app/src/androidTest/java/dev/msfjarvis/aps/util/totp/UriTotpFinderTest.kt
@@ -10,36 +10,40 @@ 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"
- }
+ 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
index fc76deef..575c5aa7 100644
--- a/app/src/androidTest/java/dev/msfjarvis/aps/util/viewmodel/StrictDomainRegexTest.kt
+++ b/app/src/androidTest/java/dev/msfjarvis/aps/util/viewmodel/StrictDomainRegexTest.kt
@@ -10,40 +10,46 @@ import kotlin.test.assertTrue
import org.junit.Test
private infix fun String.matchedForDomain(domain: String) =
- SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true
+ 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"))
- }
+ @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/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt b/app/src/free/java/dev/msfjarvis/aps/autofill/oreo/ui/AutofillSmsActivity.kt
index 89bf2730..5121a5b0 100644
--- 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
@@ -14,14 +14,14 @@ import androidx.appcompat.app.AppCompatActivity
@Suppress("UNUSED_PARAMETER")
class AutofillSmsActivity : AppCompatActivity() {
- companion object {
+ companion object {
- fun shouldOfferFillFromSms(context: Context): Boolean {
- return false
- }
+ fun shouldOfferFillFromSms(context: Context): Boolean {
+ return false
+ }
- fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender {
- throw NotImplementedError("Filling OTPs from SMS requires non-free dependencies")
- }
+ fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender {
+ throw NotImplementedError("Filling OTPs from SMS requires non-free dependencies")
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/Application.kt b/app/src/main/java/dev/msfjarvis/aps/Application.kt
index f1645c7f..d7d4c7a7 100644
--- a/app/src/main/java/dev/msfjarvis/aps/Application.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/Application.kt
@@ -22,45 +22,45 @@ import dev.msfjarvis.aps.util.settings.runMigrations
@Suppress("Unused")
class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener {
- private val prefs by lazy { sharedPrefs }
+ 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 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 onTerminate() {
+ prefs.unregisterOnSharedPreferenceChangeListener(this)
+ super.onTerminate()
+ }
- override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) {
- if (key == PreferenceKeys.APP_THEME) {
- setNightMode()
- }
+ 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
- })
- }
+ 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 {
+ companion object {
- lateinit var instance: Application
- }
+ lateinit var instance: Application
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt b/app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt
index 2a3b52cd..5b4931e7 100644
--- a/app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/data/password/FieldItem.kt
@@ -6,27 +6,30 @@
package dev.msfjarvis.aps.data.password
class FieldItem(val key: String, val value: String, val action: ActionType) {
- enum class ActionType {
- COPY, HIDE
- }
+ enum class ActionType {
+ COPY,
+ HIDE
+ }
- enum class ItemType(val type: String) {
- USERNAME("Username"), PASSWORD("Password"), OTP("OTP")
- }
+ enum class ItemType(val type: String) {
+ USERNAME("Username"),
+ PASSWORD("Password"),
+ OTP("OTP")
+ }
- companion object {
+ companion object {
- // Extra helper methods
- fun createOtpField(otp: String): FieldItem {
- return FieldItem(ItemType.OTP.type, otp, ActionType.COPY)
- }
+ // Extra helper methods
+ fun createOtpField(otp: String): FieldItem {
+ return FieldItem(ItemType.OTP.type, otp, ActionType.COPY)
+ }
- fun createPasswordField(password: String): FieldItem {
- return FieldItem(ItemType.PASSWORD.type, password, ActionType.HIDE)
- }
+ fun createPasswordField(password: String): FieldItem {
+ return FieldItem(ItemType.PASSWORD.type, password, ActionType.HIDE)
+ }
- fun createUsernameField(username: String): FieldItem {
- return FieldItem(ItemType.USERNAME.type, username, ActionType.COPY)
- }
+ fun createUsernameField(username: String): FieldItem {
+ return FieldItem(ItemType.USERNAME.type, username, ActionType.COPY)
}
+ }
}
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
index d6f3c15f..8a2ca3c6 100644
--- a/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordEntry.kt
@@ -18,178 +18,178 @@ import java.util.Date
*/
class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) {
- val password: String
- val username: String?
-
- @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
- val digits: String
-
- @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
- val totpSecret: String?
- val totpPeriod: Long
-
- @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
- val totpAlgorithm: String
- val extraContent: String
- val extraContentWithoutAuthData: String
- val extraContentMap: Map<String, String>
-
- constructor(os: ByteArrayOutputStream) : this(os.toString(Charsets.UTF_8.name()), UriTotpFinder())
-
- init {
- val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
- password = foundPassword
- extraContent = passContent.joinToString("\n")
- extraContentWithoutAuthData = generateExtraContentWithoutAuthData()
- extraContentMap = generateExtraContentPairs()
- username = findUsername()
- digits = findOtpDigits(content)
- totpSecret = findTotpSecret(content)
- totpPeriod = findTotpPeriod(content)
- totpAlgorithm = findTotpAlgorithm(content)
- }
-
- fun hasExtraContent(): Boolean {
- return extraContent.isNotEmpty()
- }
-
- fun hasExtraContentWithoutAuthData(): Boolean {
- return extraContentWithoutAuthData.isNotEmpty()
- }
-
- fun hasTotp(): Boolean {
- return totpSecret != null
+ val password: String
+ val username: String?
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val digits: String
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpSecret: String?
+ val totpPeriod: Long
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpAlgorithm: String
+ val extraContent: String
+ val extraContentWithoutAuthData: String
+ val extraContentMap: Map<String, String>
+
+ constructor(os: ByteArrayOutputStream) : this(os.toString(Charsets.UTF_8.name()), UriTotpFinder())
+
+ init {
+ val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
+ password = foundPassword
+ extraContent = passContent.joinToString("\n")
+ extraContentWithoutAuthData = generateExtraContentWithoutAuthData()
+ extraContentMap = generateExtraContentPairs()
+ username = findUsername()
+ digits = findOtpDigits(content)
+ totpSecret = findTotpSecret(content)
+ totpPeriod = findTotpPeriod(content)
+ totpAlgorithm = findTotpAlgorithm(content)
+ }
+
+ fun hasExtraContent(): Boolean {
+ return extraContent.isNotEmpty()
+ }
+
+ fun hasExtraContentWithoutAuthData(): Boolean {
+ return extraContentWithoutAuthData.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()
+ }
+
+ private fun generateExtraContentWithoutAuthData(): String {
+ var foundUsername = false
+ return extraContent
+ .lineSequence()
+ .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 generateExtraContentPairs(): Map<String, String> {
+ fun MutableMap<String, String>.putOrAppend(key: String, value: String) {
+ if (value.isEmpty()) return
+ val existing = this[key]
+ this[key] =
+ if (existing == null) {
+ value
+ } else {
+ "$existing\n$value"
+ }
}
- fun hasUsername(): Boolean {
- return username != null
+ val items = mutableMapOf<String, String>()
+ // Take extraContentWithoutAuthData and onEach line perform the following tasks
+ extraContentWithoutAuthData.lines().forEach { line ->
+ // Split the line on ':' and save all the parts into an array
+ // "ABC : DEF:GHI" --> ["ABC", "DEF", "GHI"]
+ val splitArray = line.split(":")
+ // Take the first element of the array. This will be the key for the key-value pair.
+ // ["ABC ", " DEF", "GHI"] -> key = "ABC"
+ val key = splitArray.first().trimEnd()
+ // Remove the first element from the array and join the rest of the string again with
+ // ':' as separator.
+ // ["ABC ", " DEF", "GHI"] -> value = "DEF:GHI"
+ val value = splitArray.drop(1).joinToString(":").trimStart()
+
+ if (key.isNotEmpty() && value.isNotEmpty()) {
+ // If both key and value are not empty, we can form a pair with this so add it to
+ // the map.
+ // key = "ABC", value = "DEF:GHI"
+ items[key] = value
+ } else {
+ // If either key or value is empty, we were not able to form proper key-value pair.
+ // So append the original line into an "EXTRA CONTENT" map entry
+ items.putOrAppend(EXTRA_CONTENT, line)
+ }
}
- fun calculateTotpCode(): String? {
- if (totpSecret == null)
- return null
- return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get()
- }
+ return items
+ }
- private fun generateExtraContentWithoutAuthData(): String {
- var foundUsername = false
- return extraContent
- .lineSequence()
- .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()
+ }
}
-
- private fun generateExtraContentPairs(): Map<String, String> {
- fun MutableMap<String, String>.putOrAppend(key: String, value: String) {
- if (value.isEmpty()) return
- val existing = this[key]
- this[key] = if (existing == null) {
- value
- } else {
- "$existing\n$value"
- }
+ return null
+ }
+
+ private fun findAndStripPassword(passContent: List<String>): Pair<String, List<String>> {
+ 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))
}
-
- val items = mutableMapOf<String, String>()
- // Take extraContentWithoutAuthData and onEach line perform the following tasks
- extraContentWithoutAuthData.lines().forEach { line ->
- // Split the line on ':' and save all the parts into an array
- // "ABC : DEF:GHI" --> ["ABC", "DEF", "GHI"]
- val splitArray = line.split(":")
- // Take the first element of the array. This will be the key for the key-value pair.
- // ["ABC ", " DEF", "GHI"] -> key = "ABC"
- val key = splitArray.first().trimEnd()
- // Remove the first element from the array and join the rest of the string again with ':' as separator.
- // ["ABC ", " DEF", "GHI"] -> value = "DEF:GHI"
- val value = splitArray.drop(1).joinToString(":").trimStart()
-
- if (key.isNotEmpty() && value.isNotEmpty()) {
- // If both key and value are not empty, we can form a pair with this so add it to the map.
- // key = "ABC", value = "DEF:GHI"
- items[key] = value
- } else {
- // If either key or value is empty, we were not able to form proper key-value pair.
- // So append the original line into an "EXTRA CONTENT" map entry
- items.putOrAppend(EXTRA_CONTENT, line)
- }
- }
-
- return items
+ }
}
+ return Pair(passContent[0], passContent.minus(passContent[0]))
+ }
- 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 findTotpSecret(decryptedContent: String): String? {
+ return totpFinder.findSecret(decryptedContent)
+ }
- private fun findAndStripPassword(passContent: List<String>): Pair<String, List<String>> {
- 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 findOtpDigits(decryptedContent: String): String {
+ return totpFinder.findDigits(decryptedContent)
+ }
- private fun findTotpSecret(decryptedContent: String): String? {
- return totpFinder.findSecret(decryptedContent)
- }
+ private fun findTotpPeriod(decryptedContent: String): Long {
+ return totpFinder.findPeriod(decryptedContent)
+ }
- private fun findOtpDigits(decryptedContent: String): String {
- return totpFinder.findDigits(decryptedContent)
- }
+ private fun findTotpAlgorithm(decryptedContent: String): String {
+ return totpFinder.findAlgorithm(decryptedContent)
+ }
- private fun findTotpPeriod(decryptedContent: String): Long {
- return totpFinder.findPeriod(decryptedContent)
- }
+ companion object {
- private fun findTotpAlgorithm(decryptedContent: String): String {
- return totpFinder.findAlgorithm(decryptedContent)
- }
+ private const val EXTRA_CONTENT = "Extra Content"
- companion object {
-
- private const val EXTRA_CONTENT = "Extra Content"
-
- @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
- val USERNAME_FIELDS = arrayOf(
- "login:",
- "username:",
- "user:",
- "account:",
- "email:",
- "name:",
- "handle:",
- "id:",
- "identity:",
- )
-
- @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
- val PASSWORD_FIELDS = arrayOf(
- "password:",
- "secret:",
- "pass:",
- )
- }
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ val USERNAME_FIELDS =
+ arrayOf(
+ "login:",
+ "username:",
+ "user:",
+ "account:",
+ "email:",
+ "name:",
+ "handle:",
+ "id:",
+ "identity:",
+ )
+
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ 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
index 60889150..89923dff 100644
--- a/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/data/password/PasswordItem.kt
@@ -8,79 +8,56 @@ 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
+ val name: String,
+ val parent: PasswordItem? = null,
+ val type: Char,
+ val file: File,
+ val rootDir: File
) : Comparable<PasswordItem> {
- val fullPathToParent = file.absolutePath
- .replace(rootDir.absolutePath, "")
- .replace(file.name, "")
+ val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "")
- val longName = BasePgpActivity.getLongName(
- fullPathToParent,
- rootDir.absolutePath,
- toString())
+ val longName = BasePgpActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString())
- override fun equals(other: Any?): Boolean {
- return (other is PasswordItem) && (other.file == file)
- }
+ 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 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 toString(): String {
+ return name.replace("\\.gpg$".toRegex(), "")
+ }
- override fun hashCode(): Int {
- return 0
- }
+ override fun hashCode(): Int {
+ return 0
+ }
- companion object {
+ companion object {
- const val TYPE_CATEGORY = 'c'
- const val TYPE_PASSWORD = 'p'
+ 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, 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 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, 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)
- }
+ @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
index 1f12918d..5aa63e76 100644
--- a/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/data/repo/PasswordRepository.kt
@@ -31,213 +31,211 @@ 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() {
+ @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 supportsSymlinks() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
- override fun isSymLink(file: File) = Files.isSymbolicLink(file.toPath())
+ override fun isSymLink(file: File) = Files.isSymbolicLink(file.toPath())
- override fun readSymLink(file: File) = Files.readSymbolicLink(file.toPath()).toString()
+ 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())
- }
+ 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() {
+ @RequiresApi(Build.VERSION_CODES.O)
+ private class Java7FSFactory : FS.FSFactory() {
- override fun detect(cygwinUsed: Boolean?): FS {
- return FS_POSIX_Java6_with_optional_symlinks()
- }
+ 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
+ }
+
+ 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()
}
- return repository
- }
-
- @JvmStatic
- val isInitialized: Boolean
- get() = repository != null
-
- @JvmStatic
- fun isGitRepo(): Boolean {
- if (repository != null) {
- return repository!!.objectDatabase.exists()
- }
- return false
+ .getOrElse { e ->
+ e.printStackTrace()
+ null
+ }
}
+ return repository
+ }
- @JvmStatic
- @Throws(Exception::class)
- fun createRepository(localDir: File) {
- localDir.delete()
+ @JvmStatic
+ val isInitialized: Boolean
+ get() = repository != null
- Git.init().setDirectory(localDir).call()
- getRepository(localDir)
+ @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])
+ }
- // 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)
+ remoteConfig.addURI(uri)
+ remoteConfig.addPushURI(uri)
- 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()
- }
- }
- }
+ remoteConfig.update(storedConfig)
- @JvmStatic
- fun closeRepository() {
- if (repository != null) repository!!.close()
- repository = null
+ storedConfig.save()
+ }
+ .onFailure { e -> e.printStackTrace() }
}
-
- @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 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"))
+ }
+
+ @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)
+ }
}
- /**
- * 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<File> {
- 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<File>()
- items.addAll(directories)
- items.addAll(files)
-
- return items
+ // 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<File> {
+ 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<File>()
+ 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<PasswordItem> {
+ // We need to recover the passwords then parse the files
+ val passList = getFilesList(path).also { it.sortBy { f -> f.name } }
+ val passwordList = ArrayList<PasswordItem>()
+ val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
+
+ if (passList.size == 0) return passwordList
+ if (!showHidden) {
+ passList.filter { !it.isHidden }.toCollection(passList.apply { clear() })
}
-
- /**
- * 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<PasswordItem> {
- // We need to recover the passwords then parse the files
- val passList = getFilesList(path).also { it.sortBy { f -> f.name } }
- val passwordList = ArrayList<PasswordItem>()
- val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
-
- if (passList.size == 0) return passwordList
- if (!showHidden) {
- passList.filter { !it.isHidden }.toCollection(passList.apply { clear() })
- }
- passList.forEach { file ->
- passwordList.add(if (file.isFile) {
- PasswordItem.newPassword(file.name, file, rootDir)
- } else {
- PasswordItem.newCategory(file.name, file, rootDir)
- })
+ 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
+ )
}
+ passwordList.sortWith(sortOrder.comparator)
+ return passwordList
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt
index 0822fdd3..afb2a130 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/FieldItemAdapter.kt
@@ -17,74 +17,74 @@ import dev.msfjarvis.aps.data.password.FieldItem
import dev.msfjarvis.aps.databinding.ItemFieldBinding
class FieldItemAdapter(
- private var fieldItemList: List<FieldItem>,
- private val showPassword: Boolean,
- private val copyTextToClipBoard: (text: String?) -> Unit,
+ private var fieldItemList: List<FieldItem>,
+ private val showPassword: Boolean,
+ private val copyTextToClipBoard: (text: String?) -> Unit,
) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() {
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
- val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
- return FieldItemViewHolder(binding.root, binding)
- }
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
+ val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
+ return FieldItemViewHolder(binding.root, binding)
+ }
- override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
- holder.bind(fieldItemList[position], showPassword, copyTextToClipBoard)
- }
+ override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
+ holder.bind(fieldItemList[position], showPassword, copyTextToClipBoard)
+ }
- override fun getItemCount(): Int {
- return fieldItemList.size
- }
-
- fun updateOTPCode(code: String) {
- var otpItemPosition = -1;
- fieldItemList = fieldItemList.mapIndexed { position, item ->
- if (item.key.equals(FieldItem.ItemType.OTP.type, true)) {
- otpItemPosition = position
- return@mapIndexed FieldItem.createOtpField(code)
- }
+ override fun getItemCount(): Int {
+ return fieldItemList.size
+ }
- return@mapIndexed item
+ fun updateOTPCode(code: String) {
+ var otpItemPosition = -1
+ fieldItemList =
+ fieldItemList.mapIndexed { position, item ->
+ if (item.key.equals(FieldItem.ItemType.OTP.type, true)) {
+ otpItemPosition = position
+ return@mapIndexed FieldItem.createOtpField(code)
}
- notifyItemChanged(otpItemPosition)
- }
+ return@mapIndexed item
+ }
- fun updateItems(itemList: List<FieldItem>) {
- fieldItemList = itemList
- notifyDataSetChanged()
- }
+ notifyItemChanged(otpItemPosition)
+ }
+
+ fun updateItems(itemList: List<FieldItem>) {
+ fieldItemList = itemList
+ notifyDataSetChanged()
+ }
- class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) :
- RecyclerView.ViewHolder(itemView) {
+ class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : RecyclerView.ViewHolder(itemView) {
- fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) {
- with(binding) {
- itemText.hint = fieldItem.key
- itemTextContainer.hint = fieldItem.key
- itemText.setText(fieldItem.value)
+ fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) {
+ with(binding) {
+ itemText.hint = fieldItem.key
+ itemTextContainer.hint = fieldItem.key
+ itemText.setText(fieldItem.value)
- when (fieldItem.action) {
- FieldItem.ActionType.COPY -> {
- itemTextContainer.apply {
- endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
- endIconMode = TextInputLayout.END_ICON_CUSTOM
- setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
- }
- }
- FieldItem.ActionType.HIDE -> {
- itemTextContainer.apply {
- endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
- setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
- }
- itemText.apply {
- if (!showPassword) {
- transformationMethod = PasswordTransformationMethod.getInstance()
- }
- setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
- }
- }
- }
+ when (fieldItem.action) {
+ FieldItem.ActionType.COPY -> {
+ itemTextContainer.apply {
+ endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
+ endIconMode = TextInputLayout.END_ICON_CUSTOM
+ setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
+ }
+ }
+ FieldItem.ActionType.HIDE -> {
+ itemTextContainer.apply {
+ endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
+ setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
+ }
+ itemText.apply {
+ if (!showPassword) {
+ transformationMethod = PasswordTransformationMethod.getInstance()
+ }
+ setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
}
+ }
}
+ }
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt
index b68059ad..fc3b1a7e 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/adapters/PasswordItemRecyclerAdapter.kt
@@ -19,65 +19,66 @@ import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter
import dev.msfjarvis.aps.util.viewmodel.stableId
open class PasswordItemRecyclerAdapter :
- SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>(
- R.layout.password_row_layout,
- ::PasswordItemViewHolder,
- PasswordItemViewHolder::bind
- ) {
+ SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>(
+ R.layout.password_row_layout,
+ ::PasswordItemViewHolder,
+ PasswordItemViewHolder::bind
+ ) {
- fun makeSelectable(recyclerView: RecyclerView) {
- makeSelectable(recyclerView, ::PasswordItemDetailsLookup)
- }
+ 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 onItemClicked(
+ listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit
+ ): PasswordItemRecyclerAdapter {
+ return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
+ }
- override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter {
- return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter
- }
+ override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter {
+ return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter
+ }
- class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
- private val name: AppCompatTextView = itemView.findViewById(R.id.label)
- private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count)
- private val folderIndicator: AppCompatImageView =
- itemView.findViewById(R.id.folder_indicator)
- lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String>
+ private val name: AppCompatTextView = itemView.findViewById(R.id.label)
+ private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count)
+ private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator)
+ lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String>
- 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<String>() {
- override fun getPosition() = absoluteAdapterPosition
- override fun getSelectionKey() = item.stableId
- }
+ 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<String>() {
+ override fun getPosition() = absoluteAdapterPosition
+ override fun getSelectionKey() = item.stableId
}
}
+ }
- class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) :
- ItemDetailsLookup<String>() {
+ class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup<String>() {
- override fun getItemDetails(event: MotionEvent): ItemDetails<String>? {
- val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null
- return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails
- }
+ override fun getItemDetails(event: MotionEvent): ItemDetails<String>? {
+ val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null
+ return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt
index 94274c37..ab1f402d 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillDecryptActivity.kt
@@ -51,195 +51,184 @@ import org.openintents.openpgp.OpenPgpError
@RequiresApi(Build.VERSION_CODES.O)
class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
- companion object {
+ 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 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
+ 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 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
+ 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 val decryptInteractionRequiredAction =
+ registerForActivityResult(StartIntentSenderForResult()) { result ->
+ if (continueAfterUserInteraction != null) {
+ val data = result.data
+ if (result.resultCode == RESULT_OK && data != null) {
+ continueAfterUserInteraction?.resume(data)
+ } else {
+ continueAfterUserInteraction?.resumeWithException(
+ Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")
+ )
}
+ continueAfterUserInteraction = null
+ }
}
- private var continueAfterUserInteraction: Continuation<Intent>? = null
- private lateinit var directoryStructure: DirectoryStructure
+ private var continueAfterUserInteraction: Continuation<Intent>? = null
+ private lateinit var directoryStructure: DirectoryStructure
- override val coroutineContext
- get() = Dispatchers.IO + SupervisorJob()
+ 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
+ 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 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()
- }
+ 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()
- }
+ 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<IOpenPgpService2> { cont ->
- openPgpServiceConnection = OpenPgpServiceConnection(
- this,
- OPENPGP_PROVIDER,
- object : OpenPgpServiceConnection.OnBound {
- override fun onBound(service: IOpenPgpService2) {
- cont.resume(service)
- }
+ private suspend fun executeOpenPgpApi(data: Intent, input: InputStream, output: OutputStream): Intent? {
+ var openPgpServiceConnection: OpenPgpServiceConnection? = null
+ val openPgpService =
+ suspendCoroutine<IOpenPgpService2> { cont ->
+ openPgpServiceConnection =
+ OpenPgpServiceConnection(
+ this,
+ OPENPGP_PROVIDER,
+ object : OpenPgpServiceConnection.OnBound {
+ override fun onBound(service: IOpenPgpService2) {
+ cont.resume(service)
+ }
- override fun onError(e: Exception) {
- cont.resumeWithException(e)
- }
- }).also { it.bindToService() }
- }
- return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
- openPgpServiceConnection?.unbindFromService()
- }
+ 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" }
+ 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 { 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<Intent> { 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
- }
+ }
+ .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))
}
- OpenPgpApi.RESULT_CODE_ERROR -> {
- val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR)
- if (error != null) {
- withContext(Dispatchers.Main) {
- Toast.makeText(
- applicationContext,
- "Error from OpenKeyChain: ${error.message}",
- Toast.LENGTH_LONG
- ).show()
- }
- e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" }
- }
- null
- }
- else -> {
- e { "Unrecognized OpenPgpApi result: $resultCode" }
- null
+ 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<Intent> { 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<OpenPgpError>(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
- }
+ }
+ }
+ 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
index ffff8bbd..cf833a19 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillFilterActivity.kt
@@ -41,180 +41,164 @@ 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
+ 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()
+ }
+
+ 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)
+ 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
+ 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
+ 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)!!)
}
- 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
- }
+ intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> {
+ FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
}
- 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() }
+ 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"
}
- 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()
+ 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
- ))
- }
+ }
+
+ 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
index faf1f1c0..bea2bcd3 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillPublisherChangedActivity.kt
@@ -31,84 +31,83 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
@TargetApi(Build.VERSION_CODES.O)
class AutofillPublisherChangedActivity : AppCompatActivity() {
- companion object {
+ 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
+ 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
+ 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)
+ 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)
+ 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<FillResponse>(EXTRA_FILL_RESPONSE_AFTER_RESET)
- setResult(RESULT_OK, Intent().apply {
- putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse)
- })
- finish()
- }
+ 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<FillResponse>(EXTRA_FILL_RESPONSE_AFTER_RESET)
+ setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) })
+ finish()
+ }
}
+ }
- private fun showPackageInfo() {
- runCatching {
- with(binding) {
- val packageInfo =
- packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
- val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
- warningAppInstallDate.text =
- getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
- val appInfo =
- packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
- warningAppName.text = "“${packageManager.getApplicationLabel(appInfo)}”"
+ 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()
- }
+ 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
index f307d481..dfec29be 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/AutofillSaveActivity.kt
@@ -29,121 +29,106 @@ import java.io.File
@RequiresApi(Build.VERSION_CODES.O)
class AutofillSaveActivity : AppCompatActivity() {
- companion object {
+ 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 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
+ 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
+ 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)
)
- 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
+ )
}
+ 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
- }
+ 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)
+ 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 {
- setResult(RESULT_CANCELED)
+ // Password was extracted from a form, there is nothing to fill.
+ Intent()
}
- finish()
- }.launch(saveIntent)
- }
+ 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
index 2b708591..eacd49c3 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/autofill/PasswordViewHolder.kt
@@ -11,6 +11,6 @@ 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)
+ 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
index 5dee98cb..fc03b76b 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/BasePgpActivity.kt
@@ -42,269 +42,249 @@ 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 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")!! }
+ /** 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 }
+ /**
+ * 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
- )
- )
- }
+ /** 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 }
+ /** [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
+ /**
+ * 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
+ /**
+ * 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)
- }
+ /**
+ * [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
- }
+ /**
+ * [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) }
- }
+ /**
+ * [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)
- }
+ /**
+ * 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
- }
+ /**
+ * 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()
- }
+ /** 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<PendingIntent>(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()
- }
+ /**
+ * 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<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender
+ }
- return DateUtils.getRelativeTimeSpanString(this, timeStamp, true)
+ /** 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()
}
- /**
- * 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}" }
- }
- }
+ 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 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)
+ /**
+ * 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
+ 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))
+ 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 {
+ companion object {
- private const val TAG = "APS/BasePgpActivity"
- const val KEY_PWGEN_TYPE_CLASSIC = "classic"
- const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
+ 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 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(), "/")
- }
+ /** 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
- }
+ /** /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
index 2a1af099..9e70bb98 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/DecryptActivity.kt
@@ -37,202 +37,196 @@ import org.openintents.openpgp.IOpenPgpService2
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
- private val binding by viewBinding(DecryptLayoutBinding::inflate)
+ private val binding by viewBinding(DecryptLayoutBinding::inflate)
- private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
- private var passwordEntry: PasswordEntry? = null
+ 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()
- }
+ private val userInteractionRequiredResult =
+ registerForActivityResult(StartIntentSenderForResult()) { result ->
+ if (result.data == null) {
+ setResult(RESULT_CANCELED, null)
+ finish()
+ return@registerForActivityResult
+ }
+
+ when (result.resultCode) {
+ RESULT_OK -> decryptAndVerify(result.data)
+ RESULT_CANCELED -> {
+ setResult(RESULT_CANCELED, result.data)
+ finish()
}
+ }
}
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- bindToOpenKeychain(this)
- title = name
- with(binding) {
- setContentView(root)
- passwordCategory.text = relativeParentPath
- passwordFile.text = name
- passwordFile.setOnLongClickListener {
- copyTextToClipboard(name)
- true
- }
- passwordLastChanged.run {
- runCatching {
- text = resources.getString(R.string.last_changed, lastChangedString)
- }.onFailure {
- visibility = View.GONE
- }
- }
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ bindToOpenKeychain(this)
+ title = name
+ with(binding) {
+ setContentView(root)
+ passwordCategory.text = relativeParentPath
+ passwordFile.text = name
+ passwordFile.setOnLongClickListener {
+ copyTextToClipboard(name)
+ true
+ }
+ 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
- }
- }
+ }
+
+ 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)
- else -> return super.onOptionsItemSelected(item)
- }
- return true
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ android.R.id.home -> onBackPressed()
+ R.id.edit_password -> editPassword()
+ R.id.share_password_as_plaintext -> shareAsPlaintext()
+ R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password)
+ else -> return super.onOptionsItemSelected(item)
}
-
- override fun onBound(service: IOpenPgpService2) {
- super.onBound(service)
- decryptAndVerify()
+ return true
+ }
+
+ 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()
}
-
- 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()
+ }
+
+ /**
+ * 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 entry = PasswordEntry(outputStream)
+ val items = arrayListOf<FieldItem>()
+ val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> copyTextToClipboard(text) }
+
+ if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
+ copyPasswordToClipboard(entry.password)
+ }
+
+ passwordEntry = entry
+ invalidateOptionsMenu()
+
+ if (entry.password.isNotEmpty()) {
+ items.add(FieldItem.createPasswordField(entry.password))
+ }
+
+ if (entry.hasTotp()) {
+ 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) {
+ val code = entry.calculateTotpCode() ?: "Error"
+ items.add(FieldItem.createOtpField(code))
+ }
+ delay(remainingTime.seconds)
+ repeat(Int.MAX_VALUE) {
+ val code = entry.calculateTotpCode() ?: "Error"
+ withContext(Dispatchers.Main) { adapter.updateOTPCode(code) }
+ delay(30.seconds)
+ }
+ }
+ }
- 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)))
- }
+ if (!entry.username.isNullOrEmpty()) {
+ items.add(FieldItem.createUsernameField(entry.username))
+ }
- @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 entry = PasswordEntry(outputStream)
- val items = arrayListOf<FieldItem>()
- val adapter = FieldItemAdapter(emptyList(), showPassword) { text ->
- copyTextToClipboard(text)
- }
-
- if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
- copyPasswordToClipboard(entry.password)
- }
-
- passwordEntry = entry
- invalidateOptionsMenu()
-
- if (entry.password.isNotEmpty()) {
- items.add(FieldItem.createPasswordField(entry.password))
- }
-
- if (entry.hasTotp()) {
- 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) {
- val code = entry.calculateTotpCode() ?: "Error"
- items.add(FieldItem.createOtpField(code))
- }
- delay(remainingTime.seconds)
- repeat(Int.MAX_VALUE) {
- val code = entry.calculateTotpCode() ?: "Error"
- withContext(Dispatchers.Main) {
- adapter.updateOTPCode(code)
- }
- delay(30.seconds)
- }
- }
- }
-
- if (!entry.username.isNullOrEmpty()) {
- items.add(FieldItem.createUsernameField(entry.username))
- }
-
- if (entry.hasExtraContentWithoutAuthData()) {
- entry.extraContentMap.forEach { (key, value) ->
- items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
- }
- }
-
- binding.recyclerView.adapter = adapter
- adapter.updateItems(items)
- }.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)
+ if (entry.hasExtraContentWithoutAuthData()) {
+ entry.extraContentMap.forEach { (key, value) ->
+ items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
}
+ }
+
+ binding.recyclerView.adapter = adapter
+ adapter.updateItems(items)
}
+ .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
index 98492c00..9da4044a 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/GetKeyIdsActivity.kt
@@ -21,56 +21,54 @@ 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!!)
+ 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 onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ bindToOpenKeychain(this)
+ }
- override fun onBound(service: IOpenPgpService2) {
- super.onBound(service)
- getKeyIds()
- }
+ override fun onBound(service: IOpenPgpService2) {
+ super.onBound(service)
+ getKeyIds()
+ }
- override fun onError(e: Exception) {
- e(e)
- }
+ 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)
- }
+ /** 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
index 760faba6..1e9066b0 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/crypto/PasswordCreationActivity.kt
@@ -55,454 +55,443 @@ import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
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 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 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 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
- }
+ private fun File.findTillRoot(fileName: String, rootPath: File): File? {
+ val gpgFile = File(this, fileName)
+ if (gpgFile.exists()) return gpgFile
- val parent = parentFile
- return if (parent != null && parent.exists()) {
- parent.findTillRoot(fileName, rootPath)
- } else {
- null
- }
+ if (this.absolutePath == rootPath.absolutePath) {
+ return null
}
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- bindToOpenKeychain(this)
- title = if (editing)
- getString(R.string.edit_password)
- else
- getString(R.string.new_password_title)
- with(binding) {
- setContentView(root)
- generatePassword.setOnClickListener { generatePassword() }
- otpImportButton.setOnClickListener {
- supportFragmentManager.setFragmentResultListener(OTP_RESULT_REQUEST_KEY, this@PasswordCreationActivity) { requestKey, bundle ->
- if (requestKey == OTP_RESULT_REQUEST_KEY) {
- val contents = bundle.getString(RESULT)
- val currentExtras = binding.extraContent.text.toString()
- if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
- binding.extraContent.append("\n$contents")
- else
- binding.extraContent.append(contents)
- }
- }
- val items = arrayOf(getString(R.string.otp_import_qr_code), getString(R.string.otp_import_manual_entry))
- MaterialAlertDialogBuilder(this@PasswordCreationActivity)
- .setItems(items) { _, index ->
- if (index == 0) {
- otpImportAction.launch(IntentIntegrator(this@PasswordCreationActivity)
- .setOrientationLocked(false)
- .setBeepEnabled(false)
- .setDesiredBarcodeFormats(QR_CODE)
- .createScanIntent())
- } else if (index == 1) {
- OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
- }
- }
- .show()
+ val parent = parentFile
+ return if (parent != null && parent.exists()) {
+ parent.findTillRoot(fileName, rootPath)
+ } else {
+ null
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ bindToOpenKeychain(this)
+ title = if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title)
+ with(binding) {
+ setContentView(root)
+ generatePassword.setOnClickListener { generatePassword() }
+ otpImportButton.setOnClickListener {
+ supportFragmentManager.setFragmentResultListener(OTP_RESULT_REQUEST_KEY, this@PasswordCreationActivity) {
+ requestKey,
+ bundle ->
+ if (requestKey == OTP_RESULT_REQUEST_KEY) {
+ val contents = bundle.getString(RESULT)
+ val currentExtras = binding.extraContent.text.toString()
+ if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') binding.extraContent.append("\n$contents")
+ else binding.extraContent.append(contents)
+ }
+ }
+ val items = arrayOf(getString(R.string.otp_import_qr_code), getString(R.string.otp_import_manual_entry))
+ MaterialAlertDialogBuilder(this@PasswordCreationActivity)
+ .setItems(items) { _, index ->
+ if (index == 0) {
+ otpImportAction.launch(
+ IntentIntegrator(this@PasswordCreationActivity)
+ .setOrientationLocked(false)
+ .setBeepEnabled(false)
+ .setDesiredBarcodeFormats(QR_CODE)
+ .createScanIntent()
+ )
+ } else if (index == 1) {
+ OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
}
+ }
+ .show()
+ }
- 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)
+ 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 {
- 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
+ // 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)
+ }
}
+ }
}
- updateViewState()
+ 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
+ }
}
-
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
- menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
- return true
+ updateViewState()
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
+ return true
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ android.R.id.home -> {
+ setResult(RESULT_CANCELED)
+ onBackPressed()
+ }
+ R.id.save_password -> {
+ copy = false
+ encrypt()
+ }
+ R.id.save_and_copy_password -> {
+ copy = true
+ encrypt()
+ }
+ else -> return super.onOptionsItemSelected(item)
}
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- setResult(RESULT_CANCELED)
- onBackPressed()
- }
- R.id.save_password -> {
- copy = false
- encrypt()
- }
- R.id.save_and_copy_password -> {
- copy = true
- encrypt()
- }
- else -> return super.onOptionsItemSelected(item)
- }
- return true
+ return true
+ }
+
+ private fun generatePassword() {
+ supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { requestKey, bundle ->
+ if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
+ binding.password.setText(bundle.getString(RESULT))
+ }
}
-
- private fun generatePassword() {
- supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { requestKey, bundle ->
- if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
- binding.password.setText(bundle.getString(RESULT))
- }
- }
- when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
- KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment()
- .show(supportFragmentManager, "generator")
- KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment()
- .show(supportFragmentManager, "xkpwgenerator")
- }
+ 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 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()
}
- /**
- * 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
+ /** Encrypts the password and the extra content */
+ private fun encrypt(receivedIntent: Intent? = null) {
+ with(binding) {
+ val editName = filename.text.toString().trim()
+ val editPass = password.text.toString()
+ val editExtra = extraContent.text.toString()
+
+ if (editName.isEmpty()) {
+ snackbar(message = resources.getString(R.string.file_toast_text))
+ return@with
+ } else if (editName.contains('/')) {
+ snackbar(message = resources.getString(R.string.invalid_filename_text))
+ return@with
+ }
+
+ if (editPass.isEmpty() && editExtra.isEmpty()) {
+ snackbar(message = resources.getString(R.string.empty_toast_text))
+ return@with
+ }
+
+ if (copy) {
+ copyPasswordToClipboard(editPass)
+ }
+
+ encryptionIntent = receivedIntent ?: Intent()
+ encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT
+
+ // pass enters the key ID into `.gpg-id`.
+ val repoRoot = PasswordRepository.getRepositoryDirectory()
+ val gpgIdentifierFile =
+ File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot)
+ ?: File(repoRoot, ".gpg-id").apply { createNewFile() }
+ val gpgIdentifiers =
+ gpgIdentifierFile.readLines().filter { it.isNotBlank() }.map { line ->
+ GpgIdentifier.fromString(line)
+ ?: run {
+ // The line being empty means this is most likely an empty `.gpg-id`
+ // file
+ // we created. Skip the validation so we can make the user add a real
+ // ID.
+ if (line.isEmpty()) return@run
+ if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
+ snackbar(message = resources.getString(R.string.short_key_ids_unsupported))
+ } else {
+ snackbar(message = resources.getString(R.string.invalid_gpg_id))
+ }
+ return@with
}
-
- if (editPass.isEmpty() && editExtra.isEmpty()) {
- snackbar(message = resources.getString(R.string.empty_toast_text))
- return@with
+ }
+ if (gpgIdentifiers.isEmpty()) {
+ gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java))
+ return@with
+ }
+ val keyIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.KeyId>().map { it.id }.toLongArray()
+ if (keyIds.isNotEmpty()) {
+ encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds)
+ }
+ val userIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.UserId>().map { it.email }.toTypedArray()
+ if (userIds.isNotEmpty()) {
+ encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds)
+ }
+
+ encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, 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
}
-
- if (copy) {
- copyPasswordToClipboard(editPass)
+ val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}")
+ if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) {
+ snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}")
+ return
}
- encryptionIntent = receivedIntent ?: Intent()
- encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT
-
- // pass enters the key ID into `.gpg-id`.
- val repoRoot = PasswordRepository.getRepositoryDirectory()
- val gpgIdentifierFile = File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot)
- ?: File(repoRoot, ".gpg-id").apply { createNewFile() }
- val gpgIdentifiers = gpgIdentifierFile.readLines()
- .filter { it.isNotBlank() }
- .map { line ->
- GpgIdentifier.fromString(line) ?: run {
- // The line being empty means this is most likely an empty `.gpg-id` file
- // we created. Skip the validation so we can make the user add a real ID.
- if (line.isEmpty()) return@run
- if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
- snackbar(message = resources.getString(R.string.short_key_ids_unsupported))
- } else {
- snackbar(message = resources.getString(R.string.invalid_gpg_id))
- }
- return@with
- }
+ "${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!
+ // Additionally, if we were editing and the incoming and outgoing
+ // filenames differ, it means we renamed. Ensure that the target
+ // doesn't already exist to prevent an accidental overwrite.
+ if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists()) {
+ snackbar(message = getString(R.string.password_creation_duplicate_error))
+ return@executeApiAsync
}
- if (gpgIdentifiers.isEmpty()) {
- gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java))
- return@with
- }
- val keyIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.KeyId>().map { it.id }.toLongArray()
- if (keyIds.isNotEmpty()) {
- encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds)
- }
- val userIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.UserId>().map { it.email }.toTypedArray()
- if (userIds.isNotEmpty()) {
- encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds)
- }
- encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true)
+ if (!file.isInsideRepository()) {
+ snackbar(message = getString(R.string.message_error_destination_outside_repo))
+ return@executeApiAsync
+ }
- val content = "$editPass\n$editExtra"
- val inputStream = ByteArrayInputStream(content.toByteArray())
- val outputStream = ByteArrayOutputStream()
+ 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 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
- }
+ 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)
+ }
- "${passwordDirectory.path}/$editName.gpg"
+ 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
+ }
}
- 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!
- // Additionally, if we were editing and the incoming and outgoing
- // filenames differ, it means we renamed. Ensure that the target
- // doesn't already exist to prevent an accidental overwrite.
- if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists()) {
- snackbar(message = getString(R.string.password_creation_duplicate_error))
- return@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)
+ 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 PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
- const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
- const val RESULT = "RESULT"
- const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE"
- const val RETURN_EXTRA_NAME = "NAME"
- const val RETURN_EXTRA_LONG_NAME = "LONG_NAME"
- const val RETURN_EXTRA_USERNAME = "USERNAME"
- const val RETURN_EXTRA_PASSWORD = "PASSWORD"
- const val EXTRA_FILE_NAME = "FILENAME"
- const val EXTRA_PASSWORD = "PASSWORD"
- const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
- const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"
- const val EXTRA_EDITING = "EDITING"
- }
+ }
+
+ companion object {
+
+ private const val KEY_PWGEN_TYPE_CLASSIC = "classic"
+ private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
+ const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
+ const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
+ const val RESULT = "RESULT"
+ const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE"
+ const val RETURN_EXTRA_NAME = "NAME"
+ const val RETURN_EXTRA_LONG_NAME = "LONG_NAME"
+ const val RETURN_EXTRA_USERNAME = "USERNAME"
+ const val RETURN_EXTRA_PASSWORD = "PASSWORD"
+ const val EXTRA_FILE_NAME = "FILENAME"
+ const val EXTRA_PASSWORD = "PASSWORD"
+ const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
+ const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"
+ const val EXTRA_EDITING = "EDITING"
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt
index e92d87aa..fa188a22 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/BasicBottomSheet.kt
@@ -25,138 +25,136 @@ 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.
+ * [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?,
+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 val binding by viewBinding(BasicBottomSheetBinding::bind)
- private var behavior: BottomSheetBehavior<FrameLayout>? = null
- private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
- override fun onSlide(bottomSheet: View, slideOffset: Float) {
- }
+ private var behavior: BottomSheetBehavior<FrameLayout>? = null
+ private val bottomSheetCallback =
+ object : BottomSheetBehavior.BottomSheetCallback() {
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {}
- override fun onStateChanged(bottomSheet: View, newState: Int) {
- if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
- dismiss()
- }
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
+ dismiss()
}
+ }
}
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
- if (savedInstanceState != null) dismiss()
- return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
- }
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
- override fun onGlobalLayout() {
- view.viewTreeObserver.removeOnGlobalLayoutListener(this)
- val dialog = dialog as BottomSheetDialog? ?: return
- behavior = dialog.behavior
- behavior?.apply {
- state = BottomSheetBehavior.STATE_EXPANDED
- peekHeight = 0
- addBottomSheetCallback(bottomSheetCallback)
- }
- if (!title.isNullOrEmpty()) {
- binding.bottomSheetTitle.isVisible = true
- binding.bottomSheetTitle.text = title
- }
- binding.bottomSheetMessage.text = message
- if (positiveButtonClickListener != null) {
- positiveButtonLabel?.let { buttonLbl ->
- binding.bottomSheetOkButton.text = buttonLbl
- }
- binding.bottomSheetOkButton.isVisible = true
- binding.bottomSheetOkButton.setOnClickListener {
- positiveButtonClickListener.onClick(it)
- dismiss()
- }
- }
- if (negativeButtonClickListener != null) {
- binding.bottomSheetCancelButton.isVisible = true
- negativeButtonLabel?.let { buttonLbl ->
- binding.bottomSheetCancelButton.text = buttonLbl
- }
- binding.bottomSheetCancelButton.setOnClickListener {
- negativeButtonClickListener.onClick(it)
- dismiss()
- }
- }
+ override fun 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
+ }
+ )
+ 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
}
- override fun dismiss() {
- super.dismiss()
- behavior?.removeBottomSheetCallback(bottomSheetCallback)
+ fun setTitle(title: String): Builder {
+ this.title = title
+ return this
}
- 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 setMessageRes(@StringRes messageRes: Int): Builder {
+ this.message = context.resources.getString(messageRes)
+ return this
+ }
- fun setMessage(message: String): Builder {
- this.message = message
- 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 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 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
- )
- }
+ 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
index 177bfbe3..45dfba2b 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/FolderCreationDialogFragment.kt
@@ -30,77 +30,82 @@ import me.msfjarvis.openpgpktx.util.OpenPgpApi
class FolderCreationDialogFragment : DialogFragment() {
- private lateinit var newFolder: File
+ 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()
- }
- }
+ 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<TextInputEditText>(R.id.folder_name_text)
- dialog.setOnShowListener {
- dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
- createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!)
- }
- }
- return dialog
+ 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<TextInputEditText>(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<TextInputEditText>(R.id.folder_name_text)
- val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
- newFolder = File("$currentDir/${folderNameView.text}")
- folderNameViewContainer.error = when {
- newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
- newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
- else -> null
- }
- if (folderNameViewContainer.error != null) return
- newFolder.mkdirs()
- (requireActivity() as PasswordStore).refreshPasswordList(newFolder)
- if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
- keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
- return
- } else {
- dismiss()
- }
+ private fun createDirectory(currentDir: String) {
+ val dialog = requireDialog()
+ val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
+ val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
+ newFolder = File("$currentDir/${folderNameView.text}")
+ folderNameViewContainer.error =
+ when {
+ newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
+ newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
+ else -> null
+ }
+ if (folderNameViewContainer.error != null) return
+ newFolder.mkdirs()
+ (requireActivity() as PasswordStore).refreshPasswordList(newFolder)
+ if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
+ keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
+ return
+ } else {
+ dismiss()
}
+ }
- companion object {
+ 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
- }
+ 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
index 40a876e1..fcf7b372 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/ItemCreationBottomSheet.kt
@@ -25,53 +25,54 @@ import dev.msfjarvis.aps.util.extensions.resolveAttribute
class ItemCreationBottomSheet : BottomSheetDialogFragment() {
- private var behavior: BottomSheetBehavior<FrameLayout>? = null
- private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
- override fun onSlide(bottomSheet: View, slideOffset: Float) {
- }
+ private var behavior: BottomSheetBehavior<FrameLayout>? = null
+ private val bottomSheetCallback =
+ object : BottomSheetBehavior.BottomSheetCallback() {
+ override fun onSlide(bottomSheet: View, slideOffset: Float) {}
- override fun onStateChanged(bottomSheet: View, newState: Int) {
- if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
- dismiss()
- }
+ override fun 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 onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ if (savedInstanceState != null) dismiss()
+ return inflater.inflate(R.layout.item_create_sheet, container, false)
+ }
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
- override fun onGlobalLayout() {
- view.viewTreeObserver.removeOnGlobalLayoutListener(this)
- val dialog = dialog as BottomSheetDialog? ?: return
- behavior = dialog.behavior
- behavior?.apply {
- state = BottomSheetBehavior.STATE_EXPANDED
- peekHeight = 0
- addBottomSheetCallback(bottomSheetCallback)
- }
- dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener {
- setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER))
- dismiss()
- }
- dialog.findViewById<View>(R.id.create_password)?.setOnClickListener {
- setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD))
- dismiss()
- }
- }
- })
- val gradientDrawable = GradientDrawable().apply {
- setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ view.viewTreeObserver.addOnGlobalLayoutListener(
+ object : ViewTreeObserver.OnGlobalLayoutListener {
+ override fun onGlobalLayout() {
+ view.viewTreeObserver.removeOnGlobalLayoutListener(this)
+ val dialog = dialog as BottomSheetDialog? ?: return
+ behavior = dialog.behavior
+ behavior?.apply {
+ state = BottomSheetBehavior.STATE_EXPANDED
+ peekHeight = 0
+ addBottomSheetCallback(bottomSheetCallback)
+ }
+ dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener {
+ setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER))
+ dismiss()
+ }
+ dialog.findViewById<View>(R.id.create_password)?.setOnClickListener {
+ setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD))
+ dismiss()
+ }
}
- view.background = gradientDrawable
- }
+ }
+ )
+ val gradientDrawable =
+ GradientDrawable().apply { setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) }
+ view.background = gradientDrawable
+ }
- override fun dismiss() {
- super.dismiss()
- behavior?.removeBottomSheetCallback(bottomSheetCallback)
- }
+ override fun dismiss() {
+ super.dismiss()
+ behavior?.removeBottomSheetCallback(bottomSheetCallback)
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt
index 44c7a43a..5507218b 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/OtpImportDialogFragment.kt
@@ -20,32 +20,30 @@ import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView
class OtpImportDialogFragment : DialogFragment() {
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
- val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater)
- builder.setView(binding.root)
- builder.setPositiveButton(android.R.string.ok) { _, _ ->
- setFragmentResult(
- PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
- bundleOf(
- PasswordCreationActivity.RESULT to getTOTPUri(binding)
- )
- )
- }
- val dialog = builder.create()
- dialog.requestInputFocusOnView<TextInputEditText>(R.id.secret)
- return dialog
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater)
+ builder.setView(binding.root)
+ builder.setPositiveButton(android.R.string.ok) { _, _ ->
+ setFragmentResult(
+ PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
+ bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding))
+ )
}
+ val dialog = builder.create()
+ dialog.requestInputFocusOnView<TextInputEditText>(R.id.secret)
+ return dialog
+ }
- private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String {
- val secret = binding.secret.text.toString()
- val account = binding.account.text.toString()
- if (secret.isBlank()) return ""
- val builder = Uri.Builder()
- builder.scheme("otpauth")
- builder.authority("totp")
- builder.appendQueryParameter("secret", secret)
- if (account.isNotBlank()) builder.appendQueryParameter("issuer", account)
- return builder.build().toString()
- }
+ private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String {
+ val secret = binding.secret.text.toString()
+ val account = binding.account.text.toString()
+ if (secret.isBlank()) return ""
+ val builder = Uri.Builder()
+ builder.scheme("otpauth")
+ builder.authority("totp")
+ builder.appendQueryParameter("secret", secret)
+ if (account.isNotBlank()) builder.appendQueryParameter("issuer", account)
+ return builder.build().toString()
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt
index ad9fad1d..16a9eefb 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/PasswordGeneratorDialogFragment.kt
@@ -31,72 +31,70 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class PasswordGeneratorDialogFragment : DialogFragment() {
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
- val callingActivity = requireActivity()
- val binding = FragmentPwgenBinding.inflate(layoutInflater)
- val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
- val prefs = requireActivity().applicationContext
- .getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ val callingActivity = requireActivity()
+ val binding = FragmentPwgenBinding.inflate(layoutInflater)
+ val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
+ val prefs = requireActivity().applicationContext.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
- builder.setView(binding.root)
+ builder.setView(binding.root)
- binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false)
- binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false)
- binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false)
- binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false)
- binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false)
- binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
+ binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false)
+ binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false)
+ binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false)
+ binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false)
+ binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false)
+ binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
- binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
- binding.passwordText.typeface = monoTypeface
- return builder.run {
- setTitle(R.string.pwgen_title)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- setFragmentResult(
- PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
- bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}")
- )
- }
- setNeutralButton(R.string.dialog_cancel) { _, _ -> }
- setNegativeButton(R.string.pwgen_generate, null)
- create()
- }.apply {
- setOnShowListener {
- generate(binding.passwordText)
- getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
- generate(binding.passwordText)
- }
- }
+ binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
+ binding.passwordText.typeface = monoTypeface
+ return builder
+ .run {
+ setTitle(R.string.pwgen_title)
+ setPositiveButton(R.string.dialog_ok) { _, _ ->
+ setFragmentResult(
+ PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
+ bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}")
+ )
}
- }
-
- private fun generate(passwordField: AppCompatTextView) {
- setPreferences()
- passwordField.text = runCatching {
- generate(requireContext().applicationContext)
- }.getOrElse { e ->
- Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
- ""
+ setNeutralButton(R.string.dialog_cancel) { _, _ -> }
+ setNegativeButton(R.string.pwgen_generate, null)
+ create()
+ }
+ .apply {
+ setOnShowListener {
+ generate(binding.passwordText)
+ getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generate(binding.passwordText) }
}
- }
+ }
+ }
+
+ private fun generate(passwordField: AppCompatTextView) {
+ 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<CheckBox>(id).isChecked
- }
+ private fun isChecked(@IdRes id: Int): Boolean {
+ return requireDialog().findViewById<CheckBox>(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<EditText>(R.id.lengthNumber).text.toString()
- val length = lengthText.toIntOrNull()?.takeIf { it >= 0 }
- ?: PasswordGenerator.DEFAULT_LENGTH
- setPrefs(requireActivity().applicationContext, preferences, length)
- }
+ 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<EditText>(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
index 0e7b0a1d..e2e7426b 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/dialogs/XkPasswordGeneratorDialogFragment.kt
@@ -27,103 +27,102 @@ import dev.msfjarvis.aps.util.extensions.getString
import dev.msfjarvis.aps.util.pwgenxkpwd.CapsType
import dev.msfjarvis.aps.util.pwgenxkpwd.PasswordBuilder
-/** A placeholder fragment containing a simple view. */
+/** A placeholder fragment containing a simple view. */
class XkPasswordGeneratorDialogFragment : DialogFragment() {
- override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
- val builder = MaterialAlertDialogBuilder(requireContext())
- val callingActivity = requireActivity()
- val inflater = callingActivity.layoutInflater
- val binding = FragmentXkpwgenBinding.inflate(inflater)
- val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
- val prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
-
- builder.setView(binding.root)
-
- 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(binding, prefs)
- setFragmentResult(
- PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
- bundleOf(PasswordCreationActivity.RESULT to "${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(binding, prefs)
- makeAndSetPassword(binding)
-
- dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
- setPreferences(binding, prefs)
- makeAndSetPassword(binding)
- }
- }
- return dialog
- }
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val builder = MaterialAlertDialogBuilder(requireContext())
+ val callingActivity = requireActivity()
+ val inflater = callingActivity.layoutInflater
+ val binding = FragmentXkpwgenBinding.inflate(inflater)
+ val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
+ val prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
- private fun makeAndSetPassword(binding: FragmentXkpwgenBinding) {
- 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 = { binding.xkPasswordText.text = it },
- failure = { e ->
- Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
- tag("xkpw").e(e, "failure generating xkpasswd")
- binding.xkPasswordText.text = FALLBACK_ERROR_PASS
- },
- )
- }
+ builder.setView(binding.root)
+
+ 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))
- private fun setPreferences(binding: FragmentXkpwgenBinding, prefs: SharedPreferences) {
- 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())
- }
+ binding.xkPasswordText.typeface = monoTypeface
+
+ builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ ->
+ setPreferences(binding, prefs)
+ setFragmentResult(
+ PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
+ bundleOf(PasswordCreationActivity.RESULT to "${binding.xkPasswordText.text}")
+ )
}
- 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'
+ // 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(binding, prefs)
+ makeAndSetPassword(binding)
+
+ dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
+ setPreferences(binding, prefs)
+ makeAndSetPassword(binding)
+ }
+ }
+ return dialog
+ }
+
+ private fun makeAndSetPassword(binding: FragmentXkpwgenBinding) {
+ 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 = { binding.xkPasswordText.text = it },
+ failure = { e ->
+ Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
+ tag("xkpw").e(e, "failure generating xkpasswd")
+ binding.xkPasswordText.text = FALLBACK_ERROR_PASS
+ },
+ )
+ }
+
+ private fun setPreferences(binding: FragmentXkpwgenBinding, prefs: SharedPreferences) {
+ 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
index 910f7c27..1c6a236e 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderActivity.kt
@@ -15,49 +15,46 @@ 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
+ private lateinit var passwordList: SelectFolderFragment
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
- passwordList = SelectFolderFragment()
- val args = Bundle()
- args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
+ passwordList = SelectFolderFragment()
+ val args = Bundle()
+ args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
- passwordList.arguments = args
+ passwordList.arguments = args
- supportActionBar?.show()
+ supportActionBar?.show()
- supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
+ supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
- supportFragmentManager.commit {
- replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG)
- }
- }
+ supportFragmentManager.commit { replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) }
+ }
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.pgp_handler_select_folder, menu)
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- when (item.itemId) {
- android.R.id.home -> {
- setResult(RESULT_CANCELED)
- onBackPressed()
- }
- R.id.crypto_select -> selectFolder()
- else -> return super.onOptionsItemSelected(item)
- }
- return true
- }
+ override fun onCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.pgp_handler_select_folder, menu)
+ return true
+ }
- private fun selectFolder() {
- intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
- setResult(RESULT_OK, intent)
- finish()
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ android.R.id.home -> {
+ setResult(RESULT_CANCELED)
+ onBackPressed()
+ }
+ R.id.crypto_select -> selectFolder()
+ else -> return super.onOptionsItemSelected(item)
}
+ return true
+ }
+
+ private fun selectFolder() {
+ intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
+ setResult(RESULT_OK, intent)
+ finish()
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt
index 2a192e78..1905aab0 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/folderselect/SelectFolderFragment.kt
@@ -26,56 +26,51 @@ 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 binding by viewBinding(PasswordRecyclerViewBinding::bind)
+ private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
+ private lateinit var listener: OnFragmentInteractionListener
- private val model: SearchableRepositoryViewModel by activityViewModels()
+ 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
- }
+ 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)
+ 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)
- }
- }
+ 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)
- }
- }
+ 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")
+ }
}
}
+ .onFailure { throw ClassCastException("$context must implement OnFragmentInteractionListener") }
+ }
- val currentDir: File
- get() = model.currentDir.value!!
+ val currentDir: File
+ get() = model.currentDir.value!!
- interface OnFragmentInteractionListener {
+ interface OnFragmentInteractionListener {
- fun onFragmentInteraction(item: PasswordItem)
- }
+ 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
index a60be0d3..ac4c400d 100644
--- 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
@@ -33,132 +33,130 @@ import net.schmizz.sshj.transport.TransportException
import net.schmizz.sshj.userauth.UserAuthException
/**
- * Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related
- * tasks and makes sense to be held here.
+ * 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 {
+ /** Enum of possible Git operations than can be run through [launchGitOperation]. */
+ enum class GitOp {
+ BREAK_OUT_OF_DETACHED,
+ CLONE,
+ PULL,
+ PUSH,
+ RESET,
+ SYNC,
+ }
- 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<Unit, Throwable> {
+ if (GitSettings.url == null) {
+ return Err(IllegalStateException("Git url is not set!"))
}
-
- /**
- * Attempt to launch the requested Git operation.
- * @param operation The type of git operation to launch
- */
- suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> {
- if (GitSettings.url == null) {
- return Err(IllegalStateException("Git url is not set!"))
- }
- if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) {
- // If the server does not support multiple SSH channels per connection, we cannot run
- // a sync operation without reconnecting and thus break sync into its two parts.
- return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) }
- }
- val op = when (operation) {
- GitOp.CLONE -> CloneOperation(this, GitSettings.url!!)
- GitOp.PULL -> PullOperation(this, GitSettings.rebaseOnPull)
- GitOp.PUSH -> PushOperation(this)
- GitOp.SYNC -> SyncOperation(this, GitSettings.rebaseOnPull)
- GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this)
- GitOp.RESET -> ResetToRemoteOperation(this)
- }
- return op.executeAfterAuthentication(GitSettings.authMode).mapError(::transformGitError)
+ if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) {
+ // If the server does not support multiple SSH channels per connection, we cannot run
+ // a sync operation without reconnecting and thus break sync into its two parts.
+ return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) }
}
+ val op =
+ when (operation) {
+ GitOp.CLONE -> CloneOperation(this, GitSettings.url!!)
+ GitOp.PULL -> PullOperation(this, GitSettings.rebaseOnPull)
+ GitOp.PUSH -> PushOperation(this)
+ GitOp.SYNC -> SyncOperation(this, GitSettings.rebaseOnPull)
+ GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this)
+ GitOp.RESET -> ResetToRemoteOperation(this)
+ }
+ return op.executeAfterAuthentication(GitSettings.authMode).mapError(::transformGitError)
+ }
- fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) {
- finish()
- }
+ 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()
+ 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()
}
+ }
- /**
- * Takes the result of [launchGitOperation] and applies any necessary transformations
- * on the [throwable] returned from it
- */
- private fun transformGitError(throwable: Throwable): Throwable {
- val err = rootCauseException(throwable)
- return when {
- err.message?.contains("cannot open additional channels") == true -> {
- GitSettings.useMultiplexing = false
- SSHException(DisconnectReason.TOO_MANY_CONNECTIONS, "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used.")
- }
- err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> {
- IllegalStateException("Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings")
- }
- err is TransportException && err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> {
- SSHException(DisconnectReason.HOST_KEY_NOT_VERIFIABLE,
- "WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."
- )
- }
- else -> {
- err
- }
- }
+ /**
+ * Takes the result of [launchGitOperation] and applies any necessary transformations on the
+ * [throwable] returned from it
+ */
+ private fun transformGitError(throwable: Throwable): Throwable {
+ val err = rootCauseException(throwable)
+ return when {
+ err.message?.contains("cannot open additional channels") == true -> {
+ GitSettings.useMultiplexing = false
+ SSHException(
+ DisconnectReason.TOO_MANY_CONNECTIONS,
+ "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used."
+ )
+ }
+ err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> {
+ IllegalStateException(
+ "Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings"
+ )
+ }
+ err is TransportException && err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> {
+ SSHException(
+ DisconnectReason.HOST_KEY_NOT_VERIFIABLE,
+ "WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."
+ )
+ }
+ else -> {
+ err
+ }
}
+ }
- /**
- * Check if a given [Throwable] is the result of an error caused by the user cancelling the
- * operation.
- */
- private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean {
- var cause: Throwable? = throwable
- while (cause != null) {
- if (cause is SSHException &&
- cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER)
- return true
- cause = cause.cause
- }
- return false
+ /**
+ * 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
+ /**
+ * 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
index 75787148..a1125ef0 100644
--- 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
@@ -33,122 +33,113 @@ import org.eclipse.jgit.lib.RepositoryState
class GitConfigActivity : BaseGitActivity() {
- private val binding by viewBinding(ActivityGitConfigBinding::inflate)
+ private val binding by viewBinding(ActivityGitConfigBinding::inflate)
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ 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() }
- }
- }
+ if (GitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus()
+ else binding.gitUserName.setText(GitSettings.authorName)
+ binding.gitUserEmail.setText(GitSettings.authorEmail)
+ setupTools()
+ binding.saveButton.setOnClickListener {
+ val email = binding.gitUserEmail.text.toString().trim()
+ val name = binding.gitUserName.text.toString().trim()
+ if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) {
+ MaterialAlertDialogBuilder(this)
+ .setMessage(getString(R.string.invalid_email_dialog_text))
+ .setPositiveButton(getString(R.string.dialog_ok), null)
+ .show()
+ } else {
+ GitSettings.authorEmail = email
+ GitSettings.authorName = name
+ Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
+ Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
+ }
}
+ }
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ onBackPressed()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
}
+ }
- /**
- * Sets up the UI components of the tools section.
- */
- private fun setupTools() {
- val repo = PasswordRepository.getRepository(null)
- if (repo != null) {
- binding.gitHeadStatus.text = headStatusMsg(repo)
- // enable the abort button only if we're rebasing or merging
- val needsAbort = repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
- binding.gitAbortRebase.isEnabled = needsAbort
- binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
- }
- binding.gitLog.setOnClickListener {
- runCatching {
- startActivity(Intent(this, GitLogActivity::class.java))
- }.onFailure { ex ->
- 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()
- }
- },
+ /** 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 or merging
+ val needsAbort = repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
+ binding.gitAbortRebase.isEnabled = needsAbort
+ binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
+ }
+ binding.gitLog.setOnClickListener {
+ runCatching { startActivity(Intent(this, GitLogActivity::class.java)) }.onFailure { ex ->
+ 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)
- }
+ /**
+ * 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
index 7b34a686..56d9c043 100644
--- 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
@@ -41,233 +41,226 @@ import kotlinx.coroutines.withContext
*/
class GitServerConfigActivity : BaseGitActivity() {
- private val binding by viewBinding(ActivityGitCloneBinding::inflate)
+ private val binding by viewBinding(ActivityGitCloneBinding::inflate)
- private lateinit var newAuthMode: AuthMode
+ 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)
+ 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
+ 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)
- }
- setOnCheckedChangeListener { _, checkedId ->
- when (checkedId) {
- binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
- binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
- binding.authModePassword.id -> newAuthMode = AuthMode.Password
- View.NO_ID -> newAuthMode = AuthMode.None
- }
- }
+ binding.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)
+ }
+ setOnCheckedChangeListener { _, checkedId ->
+ when (checkedId) {
+ binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
+ binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
+ binding.authModePassword.id -> newAuthMode = AuthMode.Password
+ View.NO_ID -> newAuthMode = AuthMode.None
}
+ }
+ }
- binding.serverUrl.setText(GitSettings.url.also {
- if (it.isNullOrEmpty()) return@also
- setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
- })
- binding.serverBranch.setText(GitSettings.branch)
+ binding.serverUrl.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.serverUrl.doOnTextChanged { text, _, _, _ ->
+ if (text.isNullOrEmpty()) return@doOnTextChanged
+ setAuthModes(text.startsWith("http://") || text.startsWith("https://"))
+ }
- binding.clearHostKeyButton.isVisible = GitSettings.hasSavedHostKey()
- binding.clearHostKeyButton.setOnClickListener {
- GitSettings.clearSavedHostKey()
- Snackbar.make(binding.root, getString(R.string.clear_saved_host_key_success), Snackbar.LENGTH_LONG).show()
- it.isVisible = false
+ binding.clearHostKeyButton.isVisible = GitSettings.hasSavedHostKey()
+ binding.clearHostKeyButton.setOnClickListener {
+ GitSettings.clearSavedHostKey()
+ Snackbar.make(binding.root, getString(R.string.clear_saved_host_key_success), Snackbar.LENGTH_LONG).show()
+ it.isVisible = false
+ }
+ binding.saveButton.setOnClickListener {
+ val newUrl = binding.serverUrl.text.toString().trim()
+ // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://`
+ // in the beginning will cause the port to be seen as part of the path. Let users know
+ // about it and offer a quickfix.
+ if (newUrl.contains(PORT_REGEX)) {
+ if (newUrl.startsWith("https://")) {
+ BasicBottomSheet.Builder(this)
+ .setTitleRes(R.string.https_scheme_with_port_title)
+ .setMessageRes(R.string.https_scheme_with_port_message)
+ .setPositiveButtonClickListener { binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) }
+ .build()
+ .show(supportFragmentManager, "SSH_SCHEME_WARNING")
+ return@setOnClickListener
+ } else if (!newUrl.startsWith("ssh://")) {
+ BasicBottomSheet.Builder(this)
+ .setTitleRes(R.string.ssh_scheme_needed_title)
+ .setMessageRes(R.string.ssh_scheme_needed_message)
+ .setPositiveButtonClickListener { @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl") }
+ .build()
+ .show(supportFragmentManager, "SSH_SCHEME_WARNING")
+ return@setOnClickListener
}
- 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()
- }
- }
+ }
+ 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 -> {
- onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ onBackPressed()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
}
+ }
- private fun setAuthModes(isHttps: Boolean) = with(binding) {
- if (isHttps) {
- authModeSshKey.isVisible = false
- authModeOpenKeychain.isVisible = false
- authModePassword.isVisible = true
- if (authModeGroup.checkedChipId != authModePassword.id)
- authModeGroup.check(View.NO_ID)
- } else {
- authModeSshKey.isVisible = true
- authModeOpenKeychain.isVisible = true
- authModePassword.isVisible = true
- if (authModeGroup.checkedChipId == View.NO_ID)
- authModeGroup.check(authModeSshKey.id)
- }
+ private fun setAuthModes(isHttps: Boolean) =
+ with(binding) {
+ if (isHttps) {
+ authModeSshKey.isVisible = false
+ authModeOpenKeychain.isVisible = false
+ authModePassword.isVisible = true
+ if (authModeGroup.checkedChipId != authModePassword.id) authModeGroup.check(View.NO_ID)
+ } else {
+ authModeSshKey.isVisible = true
+ authModeOpenKeychain.isVisible = true
+ authModePassword.isVisible = true
+ if (authModeGroup.checkedChipId == 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()
- }
+ /** 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 {
- launchGitOperation(GitOp.CLONE).fold(
- success = {
- setResult(RESULT_OK)
- finish()
- },
- failure = { promptOnErrorHandler(it) },
+ 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 {
+ companion object {
- private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
+ private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
- fun createCloneIntent(context: Context): Intent {
- return Intent(context, GitServerConfigActivity::class.java).apply {
- putExtra("cloning", true)
- }
- }
+ 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
index a3415805..4265717d 100644
--- 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
@@ -20,30 +20,30 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
*/
class GitLogActivity : BaseGitActivity() {
- private val binding by viewBinding(ActivityGitLogBinding::inflate)
+ private val binding by viewBinding(ActivityGitLogBinding::inflate)
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
- createRecyclerView()
- }
+ 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)
- }
+ 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()
- }
+ 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
index 123c2af7..4b29542e 100644
--- 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
@@ -16,43 +16,42 @@ import java.text.DateFormat
import java.util.Date
private fun shortHash(hash: String): String {
- return hash.substring(0 until 8)
+ return hash.substring(0 until 8)
}
private fun stringFrom(date: Date): String {
- return DateFormat.getDateTimeInstance().format(date)
+ return DateFormat.getDateTimeInstance().format(date)
}
-/**
- * @see GitLogActivity
- */
+/** @see GitLogActivity */
class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
- private val model = GitLogModel()
+ 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 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 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
+ override fun getItemCount() = model.size
- class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
+ 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)
- }
- }
+ 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
index 2748e9c8..e3c59a50 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/main/LaunchActivity.kt
@@ -18,46 +18,46 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
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)
+ 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)
+ 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)
- }
+ Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L)
+ }
- companion object {
+ companion object {
- const val ACTION_DECRYPT_PASS = "DECRYPT_PASS"
- }
+ 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
index ec1d4e62..31ca362c 100644
--- 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
@@ -11,16 +11,16 @@ import dev.msfjarvis.aps.R
class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) {
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- supportActionBar?.hide()
- }
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ supportActionBar?.hide()
+ }
- override fun onBackPressed() {
- if (supportFragmentManager.backStackEntryCount == 0) {
- finishAffinity()
- } else {
- super.onBackPressed()
- }
+ 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
index b2092ab0..12a97d45 100644
--- 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
@@ -22,37 +22,34 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class CloneFragment : Fragment(R.layout.fragment_clone) {
- private val binding by viewBinding(FragmentCloneBinding::bind)
+ private val binding by viewBinding(FragmentCloneBinding::bind)
- private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
+ 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()
- }
+ 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())
- }
+ 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()))
- }
+ /** 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 {
+ companion object {
- fun newInstance(): CloneFragment = CloneFragment()
- }
+ 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
index 6ebde7c1..44a6d6c5 100644
--- 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
@@ -30,37 +30,37 @@ 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 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)
- ))
- }
+ 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"))
}
- } else {
- throw IllegalStateException("Failed to initialize repository state.")
+ settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
+ requireActivity().commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name)))
+ }
}
- finish()
+ } 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)) }
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.selectKey.setOnClickListener {
+ gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
}
+ }
- companion object {
+ companion object {
- fun newInstance() = KeySelectionFragment()
- }
+ 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
index 159e0664..9adbc3e8 100644
--- 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
@@ -35,159 +35,155 @@ import java.io.File
class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
- private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
- private val directorySelectIntent by lazy(LazyThreadSafetyMode.NONE) { Intent(requireContext(), DirectorySelectionActivity::class.java) }
- 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 settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
+ private val directorySelectIntent by lazy(LazyThreadSafetyMode.NONE) {
+ Intent(requireContext(), DirectorySelectionActivity::class.java)
+ }
+ 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 externalDirectorySelectAction =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == AppCompatActivity.RESULT_OK) {
+ if (checkExternalDirectory()) {
+ finish()
+ } else {
+ createRepository()
}
+ }
}
- private val externalDirPermGrantedAction = createPermGrantedAction {
- externalDirectorySelectAction.launch(directorySelectIntent)
- }
+ private val externalDirPermGrantedAction = createPermGrantedAction {
+ externalDirectorySelectAction.launch(directorySelectIntent)
+ }
- private val repositoryUsePermGrantedAction = createPermGrantedAction {
- initializeRepositoryInfo()
- }
+ private val repositoryUsePermGrantedAction = createPermGrantedAction { initializeRepositoryInfo() }
- private val repositoryChangePermGrantedAction = createPermGrantedAction {
- repositoryInitAction.launch(directorySelectIntent)
- }
+ private val repositoryChangePermGrantedAction = createPermGrantedAction {
+ repositoryInitAction.launch(directorySelectIntent)
+ }
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.hidden.setOnClickListener {
- createRepoInHiddenDir()
- }
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.hidden.setOnClickListener { createRepoInHiddenDir() }
- binding.sdcard.setOnClickListener {
- createRepoFromExternalDir()
- }
- }
+ 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 the app's private directory */
+ private fun createRepoInHiddenDir() {
+ settings.edit {
+ putBoolean(PreferenceKeys.GIT_EXTERNAL, false)
+ remove(PreferenceKeys.GIT_EXTERNAL_REPO)
}
-
- /**
- * 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(directorySelectIntent)
- }
- } 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(directorySelectIntent)
- }
- }
- .show()
+ 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(directorySelectIntent)
+ }
+ } 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()
+ }
}
- }
-
- 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
- }
+ .setNegativeButton(resources.getString(R.string.change)) { _, _ ->
+ if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ repositoryChangePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
+ } else {
+ repositoryInitAction.launch(directorySelectIntent)
+ }
}
- return false
+ .show()
}
-
- 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 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
+ }
}
-
- 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()
+ 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())
}
-
- private fun createPermGrantedAction(block: () -> Unit) =
- registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
- if (granted) {
- block.invoke()
- }
+ .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 {
+ companion object {
- fun newInstance(): RepoLocationFragment = RepoLocationFragment()
- }
+ 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
index 6b7e089f..7c3788c7 100644
--- 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
@@ -20,11 +20,13 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
@Suppress("unused")
class WelcomeFragment : Fragment(R.layout.fragment_welcome) {
- private val binding by viewBinding(FragmentWelcomeBinding::bind)
+ private val binding by viewBinding(FragmentWelcomeBinding::bind)
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- binding.letsGo.setOnClickListener { parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) }
- binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), SettingsActivity::class.java)) }
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.letsGo.setOnClickListener {
+ parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance())
}
+ binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), SettingsActivity::class.java)) }
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt
index cc93b77f..c5712353 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordFragment.kt
@@ -49,296 +49,278 @@ 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 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()
+ }
- private var recyclerViewStateToRestore: Parcelable? = null
- private var actionMode: ActionMode? = null
- private var scrollTarget: File? = null
+ 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 val model: SearchableRepositoryViewModel by activityViewModels()
- private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
- private val swipeResult = registerForActivityResult(StartActivityForResult()) {
- binding.swipeRefresher.isRefreshing = false
+ 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 } },
+ )
+ }
+ }
+ }
}
- 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()
- }
+ 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
}
- 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
- }
- },
- )
- }
- }
- }
+ 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
+ }
}
-
- 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
+ }
+ }
+ }
+
+ 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
}
-
- 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
- }
- }
+ }
+
+ // 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)
}
}
- 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
+ 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()) }
}
- }
-
- // 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)
- }
- }
- }
+ 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
+ .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 dismissActionMode() {
- actionMode?.finish()
- }
+ fun navigateTo(file: File) {
+ requireStore().clearSearch()
+ model.navigateTo(file, recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState())
+ requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
- companion object {
+ fun scrollToOnNextRefresh(file: File) {
+ scrollTarget = file
+ }
- const val ITEM_CREATION_REQUEST_KEY = "creation_key"
- const val ACTION_KEY = "action"
- const val ACTION_FOLDER = "folder"
- const val ACTION_PASSWORD = "password"
+ interface OnFragmentInteractionListener {
- 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)
- }
+ 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
index a337a189..96fce533 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/passwords/PasswordStore.kt
@@ -78,625 +78,612 @@ const val PASSWORD_FRAGMENT_TAG = "PasswordsList"
class PasswordStore : BaseGitActivity() {
- private lateinit var searchItem: MenuItem
- private val settings by lazy { sharedPrefs }
+ private lateinit var searchItem: MenuItem
+ private val settings by lazy { sharedPrefs }
- private val model: SearchableRepositoryViewModel by viewModels {
- ViewModelProvider.AndroidViewModelFactory(application)
- }
+ private val model: SearchableRepositoryViewModel by viewModels {
+ ViewModelProvider.AndroidViewModelFactory(application)
+ }
- private val storagePermissionRequest = registerForActivityResult(RequestPermission()) { granted ->
- if (granted) checkLocalRepository()
- }
+ private val storagePermissionRequest =
+ registerForActivityResult(RequestPermission()) { granted -> if (granted) checkLocalRepository() }
- private val directorySelectAction = registerForActivityResult(StartActivityForResult()) { result ->
- if (result.resultCode == RESULT_OK) {
- 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),
- )
- }
+ 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) }
+ }
}
- 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)
+ 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),
+ )
}
- }
- }
-
- 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()
+ }
+ 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),
+ )
}
+ }
}
- }
-
- 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
+ }
+ 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
}
- 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()
+ 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
}
- return super.onPrepareOptionsMenu(menu)
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- val id = item.itemId
- val initBefore = MaterialAlertDialogBuilder(this)
- .setMessage(resources.getString(R.string.creation_dialog_text))
- .setPositiveButton(resources.getString(R.string.dialog_ok), null)
- when (id) {
- R.id.user_pref -> {
- runCatching {
- startActivity(Intent(this, SettingsActivity::class.java))
- }.onFailure { e ->
- e.printStackTrace()
- }
- }
- R.id.git_push -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PUSH)
- }
- }
- R.id.git_pull -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.PULL)
- }
- }
- R.id.git_sync -> {
- if (!PasswordRepository.isInitialized) {
- initBefore.show()
- } else {
- runGitOperation(GitOp.SYNC)
- }
- }
- R.id.refresh -> refreshPasswordList()
- android.R.id.home -> onBackPressed()
- else -> return super.onOptionsItemSelected(item)
+ }
+ )
+
+ // 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
}
- return true
- }
-
- override fun onBackPressed() {
- if (getPasswordFragment()?.onBackPressedInActivity() != true)
- super.onBackPressed()
- }
-
- private fun getPasswordFragment(): PasswordFragment? {
- return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment
- }
-
- fun clearSearch() {
- if (searchItem.isActionViewExpanded)
- searchItem.collapseActionView()
- }
-
- private fun runGitOperation(operation: GitOp) = lifecycleScope.launch {
- launchGitOperation(operation).fold(
- success = { refreshPasswordList() },
- failure = { promptOnErrorHandler(it) },
- )
- }
- /**
- * 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
+ override fun onMenuItemActionExpand(item: MenuItem): Boolean {
+ return true
+ }
+ }
+ )
+ if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) {
+ searchItem.expandActionView()
+ }
+ return super.onPrepareOptionsMenu(menu)
+ }
+
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ val id = item.itemId
+ val initBefore =
+ MaterialAlertDialogBuilder(this)
+ .setMessage(resources.getString(R.string.creation_dialog_text))
+ .setPositiveButton(resources.getString(R.string.dialog_ok), null)
+ when (id) {
+ R.id.user_pref -> {
+ runCatching { startActivity(Intent(this, SettingsActivity::class.java)) }.onFailure { e -> e.printStackTrace() }
+ }
+ R.id.git_push -> {
+ if (!PasswordRepository.isInitialized) {
+ initBefore.show()
} else {
- checkLocalRepository()
- true
+ runGitOperation(GitOp.PUSH)
}
- }
-
- private fun checkLocalRepository() {
- val repo = PasswordRepository.initialize()
- if (repo == null) {
- directorySelectAction.launch(Intent(this, DirectorySelectionActivity::class.java))
+ }
+ R.id.git_pull -> {
+ if (!PasswordRepository.isInitialized) {
+ initBefore.show()
} else {
- checkLocalRepository(PasswordRepository.getRepositoryDirectory())
+ runGitOperation(GitOp.PULL)
}
- }
-
- 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)
- }
- }
+ }
+ R.id.git_sync -> {
+ if (!PasswordRepository.isInitialized) {
+ initBefore.show()
} else {
- startActivity(Intent(this, OnboardingActivity::class.java))
+ runGitOperation(GitOp.SYNC)
}
+ }
+ R.id.refresh -> refreshPasswordList()
+ android.R.id.home -> onBackPressed()
+ else -> return super.onOptionsItemSelected(item)
+ }
+ return true
+ }
+
+ override fun onBackPressed() {
+ if (getPasswordFragment()?.onBackPressedInActivity() != true) super.onBackPressed()
+ }
+
+ private fun getPasswordFragment(): PasswordFragment? {
+ return supportFragmentManager.findFragmentByTag(PASSWORD_FRAGMENT_TAG) as? PasswordFragment
+ }
+
+ fun clearSearch() {
+ if (searchItem.isActionViewExpanded) searchItem.collapseActionView()
+ }
+
+ private fun runGitOperation(operation: GitOp) =
+ lifecycleScope.launch {
+ launchGitOperation(operation)
+ .fold(
+ success = { refreshPasswordList() },
+ failure = { promptOnErrorHandler(it) },
+ )
}
- private fun 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()
+ /**
+ * 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)
}
- 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))
+ .build()
+ .show(supportFragmentManager, "STORAGE_PERMISSION_MISSING")
+ false
+ } else {
+ checkLocalRepository()
+ true
+ }
+ }
+
+ private fun checkLocalRepository() {
+ val repo = PasswordRepository.initialize()
+ if (repo == null) {
+ directorySelectAction.launch(Intent(this, DirectorySelectionActivity::class.java))
+ } 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)
}
- // 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) {
- addShortcut(item, authDecryptIntent)
+ supportActionBar?.apply {
+ show()
+ setDisplayHomeAsUpEnabled(false)
}
- }
-
- @RequiresApi(Build.VERSION_CODES.N_MR1)
- private fun addShortcut(item: PasswordItem, intent: Intent) {
- 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(intent)
- .build()
- val shortcuts = shortcutManager.dynamicShortcuts
- // If we're above or equal to the maximum shortcuts allowed, drop the last item.
- if (shortcuts.size >= MAX_SHORTCUT_COUNT) {
- shortcuts.removeLast()
+ supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
+ supportFragmentManager.commit {
+ replace(R.id.main_layout, PasswordFragment.newInstance(args), PASSWORD_FRAGMENT_TAG)
}
- // Reverse the list so we can append our new shortcut at the 'end'.
- shortcuts.reverse()
- shortcuts.add(shortcut)
- // Reverse it again, so the previous items are now in the correct order and our new item
- // is at the front like it's supposed to.
- shortcuts.reverse()
- // Write back the new shortcuts.
- shortcutManager.dynamicShortcuts = shortcuts
- }
-
- 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
+ }
+ } 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
}
- 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<PasswordItem>) {
- var size = 0
- selectedItems.forEach {
- if (it.file.isFile)
- size++
- else
- size += it.file.listFilesRecursively().size
+ 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) {
+ addShortcut(item, authDecryptIntent)
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.N_MR1)
+ private fun addShortcut(item: PasswordItem, intent: Intent) {
+ 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(intent)
+ .build()
+ val shortcuts = shortcutManager.dynamicShortcuts
+ // If we're above or equal to the maximum shortcuts allowed, drop the last item.
+ if (shortcuts.size >= MAX_SHORTCUT_COUNT) {
+ shortcuts.removeLast()
+ }
+ // Reverse the list so we can append our new shortcut at the 'end'.
+ shortcuts.reverse()
+ shortcuts.add(shortcut)
+ // Reverse it again, so the previous items are now in the correct order and our new item
+ // is at the front like it's supposed to.
+ shortcuts.reverse()
+ // Write back the new shortcuts.
+ shortcutManager.dynamicShortcuts = shortcuts
+ }
+
+ 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<PasswordItem>) {
+ var size = 0
+ selectedItems.forEach { if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size }
+ if (size == 0) {
+ selectedItems.map { item -> item.file.deleteRecursively() }
+ refreshPasswordList()
+ return
+ }
+ MaterialAlertDialogBuilder(this)
+ .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size))
+ .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ ->
+ val filesToDelete = arrayListOf<File>()
+ selectedItems.forEach { item ->
+ if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively())
+ else filesToDelete.add(item.file)
}
- if (size == 0) {
- selectedItems.map { item -> item.file.deleteRecursively() }
- refreshPasswordList()
- return
+ 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),
+ )
}
- MaterialAlertDialogBuilder(this)
- .setMessage(resources.getQuantityString(R.plurals.delete_dialog_text, size, size))
- .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ ->
- val filesToDelete = arrayListOf<File>()
- selectedItems.forEach { item ->
- if (item.file.isDirectory)
- filesToDelete.addAll(item.file.listFilesRecursively())
- else
- filesToDelete.add(item.file)
- }
- selectedItems.map { item -> item.file.deleteRecursively() }
- refreshPasswordList()
- AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
- val fmt = selectedItems.joinToString(separator = ", ") { item ->
- item.file.toRelativeString(PasswordRepository.getRepositoryDirectory())
+ }
+ .setNegativeButton(resources.getString(R.string.dialog_no), null)
+ .show()
+ }
+
+ fun movePasswords(values: List<PasswordItem>) {
+ val intent = Intent(this, SelectFolderActivity::class.java)
+ val fileLocations = values.map { it.file.absolutePath }.toTypedArray()
+ intent.putExtra("Files", fileLocations)
+ passwordMoveAction.launch(intent)
+ }
+
+ enum class CategoryRenameError(val resource: Int) {
+ None(0),
+ EmptyField(R.string.message_category_error_empty_field),
+ CategoryExists(R.string.message_category_error_category_exists),
+ DestinationOutsideRepo(R.string.message_error_destination_outside_repo),
+ }
+
+ /**
+ * Prompt the user with a new category name to assign, if the new category forms/leads a path
+ * (i.e. contains "/"), intermediate directories will be created and new category will be placed
+ * inside.
+ *
+ * @param oldCategory The category to change its name
+ * @param error Determines whether to show an error to the user in the alert dialog, this error
+ * may be due to the new category the user entered already exists or the field was empty or the
+ * destination path is outside the repository
+ *
+ * @see [CategoryRenameError]
+ * @see [isInsideRepository]
+ */
+ private fun renameCategory(oldCategory: PasswordItem, error: CategoryRenameError = CategoryRenameError.None) {
+ val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null)
+ val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text)
+
+ if (error != CategoryRenameError.None) {
+ newCategoryEditText.error = getString(error.resource)
+ }
+
+ val dialog =
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.title_rename_folder)
+ .setView(view)
+ .setMessage(getString(R.string.message_rename_folder, oldCategory.name))
+ .setPositiveButton(R.string.dialog_ok) { _, _ ->
+ val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}")
+ when {
+ newCategoryEditText.text.isNullOrBlank() -> renameCategory(oldCategory, CategoryRenameError.EmptyField)
+ newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists)
+ !newCategory.isInsideRepository() -> renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo)
+ else ->
+ lifecycleScope.launch(Dispatchers.IO) {
+ moveFile(oldCategory.file, newCategory)
+
+ // associate the new category with the last category's timestamp in
+ // history
+ val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ val timestamp = preference.getString(oldCategory.file.absolutePath.base64())
+ if (timestamp != null) {
+ preference.edit {
+ remove(oldCategory.file.absolutePath.base64())
+ putString(newCategory.absolutePath.base64(), timestamp)
+ }
}
- lifecycleScope.launch {
- commitChange(
- resources.getString(R.string.git_commit_remove_text, fmt),
- )
- }
- }
- .setNegativeButton(resources.getString(R.string.dialog_no), null)
- .show()
- }
-
- fun movePasswords(values: List<PasswordItem>) {
- val intent = Intent(this, SelectFolderActivity::class.java)
- val fileLocations = values.map { it.file.absolutePath }.toTypedArray()
- intent.putExtra("Files", fileLocations)
- passwordMoveAction.launch(intent)
- }
-
- enum class CategoryRenameError(val resource: Int) {
- None(0),
- EmptyField(R.string.message_category_error_empty_field),
- CategoryExists(R.string.message_category_error_category_exists),
- DestinationOutsideRepo(R.string.message_error_destination_outside_repo),
- }
-
- /**
- * Prompt the user with a new category name to assign,
- * if the new category forms/leads a path (i.e. contains "/"), intermediate directories will be created
- * and new category will be placed inside.
- *
- * @param oldCategory The category to change its name
- * @param error Determines whether to show an error to the user in the alert dialog,
- * this error may be due to the new category the user entered already exists or the field was empty or the
- * destination path is outside the repository
- *
- * @see [CategoryRenameError]
- * @see [isInsideRepository]
- */
- private fun renameCategory(oldCategory: PasswordItem, error: CategoryRenameError = CategoryRenameError.None) {
- val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null)
- val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text)
-
- if (error != CategoryRenameError.None) {
- newCategoryEditText.error = getString(error.resource)
- }
- val dialog = MaterialAlertDialogBuilder(this)
- .setTitle(R.string.title_rename_folder)
- .setView(view)
- .setMessage(getString(R.string.message_rename_folder, oldCategory.name))
- .setPositiveButton(R.string.dialog_ok) { _, _ ->
- val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}")
- when {
- newCategoryEditText.text.isNullOrBlank() -> renameCategory(oldCategory, CategoryRenameError.EmptyField)
- newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists)
- !newCategory.isInsideRepository() -> renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo)
- else -> lifecycleScope.launch(Dispatchers.IO) {
- moveFile(oldCategory.file, newCategory)
-
- //associate the new category with the last category's timestamp in history
- val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
- val timestamp = preference.getString(oldCategory.file.absolutePath.base64())
- if (timestamp != null) {
- preference.edit {
- remove(oldCategory.file.absolutePath.base64())
- putString(newCategory.absolutePath.base64(), timestamp)
- }
- }
-
- withContext(Dispatchers.Main) {
- commitChange(
- resources.getString(R.string.git_commit_move_text, oldCategory.name, newCategory.name),
- )
- }
- }
+ 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<TextInputEditText>(R.id.folder_name_text)
- dialog.show()
- }
-
- fun renameCategory(categories: List<PasswordItem>) {
- for (oldCategory in categories) {
- renameCategory(oldCategory)
- }
- }
-
- /**
- * Refreshes the password list by re-executing the last navigation or search action, preserving
- * the navigation stack and scroll position. If the current directory no longer exists,
- * navigation is reset to the repository root. If the optional [target] argument is provided,
- * it will be entered if it is a directory or scrolled into view if it is a file (both inside
- * the current directory).
- */
- fun refreshPasswordList(target: File? = null) {
- val plist = getPasswordFragment()
- if (target?.isDirectory == true && model.currentDir.value?.contains(target) == true) {
- plist?.navigateTo(target)
- } else if (target?.isFile == true && model.currentDir.value?.contains(target) == true) {
- // Creating new passwords is handled by an activity, so we will refresh in onStart.
- plist?.scrollToOnNextRefresh(target)
- } else if (model.currentDir.value?.isDirectory == true) {
- model.forceRefresh()
- } else {
- model.reset()
- supportActionBar!!.setDisplayHomeAsUpEnabled(false)
- }
- }
-
- private val currentDir: File
- get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory()
-
- private suspend fun moveFile(source: File, destinationFile: File) {
- val sourceDestinationMap = if (source.isDirectory) {
- destinationFile.mkdirs()
- // Recursively list all files (not directories) below `source`, then
- // obtain the corresponding target file by resolving the relative path
- // starting at the destination folder.
- source.listFilesRecursively().associateWith { destinationFile.resolve(it.relativeTo(source)) }
- } else {
- mapOf(source to destinationFile)
- }
- if (!source.renameTo(destinationFile)) {
- 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 {
-
- // The max shortcut count from the system is set to 15 for some godforsaken reason, which
- // makes zero sense and is why our update logic just never worked. Capping it at 4 which is
- // what most launchers seem to have agreed upon is the only reasonable solution.
- private const val MAX_SHORTCUT_COUNT = 4
- 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)
+ }
+ }
}
- }
+ .setNegativeButton(R.string.dialog_skip, null)
+ .create()
+
+ dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
+ dialog.show()
+ }
+
+ fun renameCategory(categories: List<PasswordItem>) {
+ for (oldCategory in categories) {
+ renameCategory(oldCategory)
+ }
+ }
+
+ /**
+ * Refreshes the password list by re-executing the last navigation or search action, preserving
+ * the navigation stack and scroll position. If the current directory no longer exists, navigation
+ * is reset to the repository root. If the optional [target] argument is provided, it will be
+ * entered if it is a directory or scrolled into view if it is a file (both inside the current
+ * directory).
+ */
+ fun refreshPasswordList(target: File? = null) {
+ val plist = getPasswordFragment()
+ if (target?.isDirectory == true && model.currentDir.value?.contains(target) == true) {
+ plist?.navigateTo(target)
+ } else if (target?.isFile == true && model.currentDir.value?.contains(target) == true) {
+ // Creating new passwords is handled by an activity, so we will refresh in onStart.
+ plist?.scrollToOnNextRefresh(target)
+ } else if (model.currentDir.value?.isDirectory == true) {
+ model.forceRefresh()
+ } else {
+ model.reset()
+ supportActionBar!!.setDisplayHomeAsUpEnabled(false)
+ }
+ }
+
+ private val currentDir: File
+ get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory()
+
+ private suspend fun moveFile(source: File, destinationFile: File) {
+ val sourceDestinationMap =
+ if (source.isDirectory) {
+ destinationFile.mkdirs()
+ // Recursively list all files (not directories) below `source`, then
+ // obtain the corresponding target file by resolving the relative path
+ // starting at the destination folder.
+ source.listFilesRecursively().associateWith { destinationFile.resolve(it.relativeTo(source)) }
+ } else {
+ mapOf(source to destinationFile)
+ }
+ if (!source.renameTo(destinationFile)) {
+ 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 {
+
+ // The max shortcut count from the system is set to 15 for some godforsaken reason, which
+ // makes zero sense and is why our update logic just never worked. Capping it at 4 which is
+ // what most launchers seem to have agreed upon is the only reasonable solution.
+ private const val MAX_SHORTCUT_COUNT = 4
+ 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
index 72e1d873..3841438e 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/proxy/ProxySelectorActivity.kt
@@ -27,49 +27,39 @@ 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() }
+ 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)
- }
- }
+ 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() }
+ private fun saveSettings() {
+ proxyPrefs.edit {
+ binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyHost = it }
+ binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyUsername = it }
+ binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let { GitSettings.proxyPort = it.toInt() }
+ binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyPassword = it }
}
+ ProxyUtils.setDefaultProxy()
+ Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt
index 20ca403c..ee6b468b 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/settings/AutofillSettings.kt
@@ -33,94 +33,94 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider {
- private val isAutofillServiceEnabled: Boolean
- get() {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
- return activity.autofillManager?.hasEnabledAutofillServices() == true
- }
+ private val isAutofillServiceEnabled: Boolean
+ get() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
+ return activity.autofillManager?.hasEnabledAutofillServices() == true
+ }
- @RequiresApi(Build.VERSION_CODES.O)
- private fun showAutofillDialog(pref: SwitchPreference) {
- val observer = LifecycleEventObserver { _, event ->
- when (event) {
- Lifecycle.Event.ON_RESUME -> {
- pref.checked = isAutofillServiceEnabled
- }
- else -> {
- }
- }
+ @RequiresApi(Build.VERSION_CODES.O)
+ private fun showAutofillDialog(pref: SwitchPreference) {
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_RESUME -> {
+ pref.checked = isAutofillServiceEnabled
}
- MaterialAlertDialogBuilder(activity).run {
- setTitle(R.string.pref_autofill_enable_title)
- @SuppressLint("InflateParams")
- val layout =
- activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
- val supportedBrowsersTextView =
- layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
- supportedBrowsersTextView.text =
- getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(
- separator = "\n"
- ) {
- val appLabel = it.first
- val supportDescription = when (it.second) {
- BrowserAutofillSupportLevel.None -> activity.getString(R.string.oreo_autofill_no_support)
- BrowserAutofillSupportLevel.FlakyFill -> activity.getString(R.string.oreo_autofill_flaky_fill_support)
- BrowserAutofillSupportLevel.PasswordFill -> activity.getString(R.string.oreo_autofill_password_fill_support)
- BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility -> activity.getString(R.string.oreo_autofill_password_fill_and_conditional_save_support)
- BrowserAutofillSupportLevel.GeneralFill -> activity.getString(R.string.oreo_autofill_general_fill_support)
- BrowserAutofillSupportLevel.GeneralFillAndSave -> activity.getString(R.string.oreo_autofill_general_fill_and_save_support)
- }
- "$appLabel: $supportDescription"
- }
- setView(layout)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
- data = Uri.parse("package:${BuildConfig.APPLICATION_ID}")
- }
- activity.startActivity(intent)
+ else -> {}
+ }
+ }
+ MaterialAlertDialogBuilder(activity).run {
+ setTitle(R.string.pref_autofill_enable_title)
+ @SuppressLint("InflateParams")
+ val layout = activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
+ val supportedBrowsersTextView = layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
+ supportedBrowsersTextView.text =
+ getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(separator = "\n") {
+ val appLabel = it.first
+ val supportDescription =
+ when (it.second) {
+ BrowserAutofillSupportLevel.None -> activity.getString(R.string.oreo_autofill_no_support)
+ BrowserAutofillSupportLevel.FlakyFill -> activity.getString(R.string.oreo_autofill_flaky_fill_support)
+ BrowserAutofillSupportLevel.PasswordFill ->
+ activity.getString(R.string.oreo_autofill_password_fill_support)
+ BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility ->
+ activity.getString(R.string.oreo_autofill_password_fill_and_conditional_save_support)
+ BrowserAutofillSupportLevel.GeneralFill -> activity.getString(R.string.oreo_autofill_general_fill_support)
+ BrowserAutofillSupportLevel.GeneralFillAndSave ->
+ activity.getString(R.string.oreo_autofill_general_fill_and_save_support)
}
- setNegativeButton(R.string.dialog_cancel, null)
- setOnDismissListener { pref.checked = isAutofillServiceEnabled }
- activity.lifecycle.addObserver(observer)
- show()
+ "$appLabel: $supportDescription"
}
+ setView(layout)
+ setPositiveButton(R.string.dialog_ok) { _, _ ->
+ val intent =
+ Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
+ data = Uri.parse("package:${BuildConfig.APPLICATION_ID}")
+ }
+ activity.startActivity(intent)
+ }
+ setNegativeButton(R.string.dialog_cancel, null)
+ setOnDismissListener { pref.checked = isAutofillServiceEnabled }
+ activity.lifecycle.addObserver(observer)
+ show()
}
+ }
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- switch(PreferenceKeys.AUTOFILL_ENABLE) {
- titleRes = R.string.pref_autofill_enable_title
- visible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
- defaultValue = isAutofillServiceEnabled
- onClick {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return@onClick true
- if (isAutofillServiceEnabled) {
- activity.autofillManager?.disableAutofillServices()
- } else {
- showAutofillDialog(this)
- }
- false
- }
- }
- val values = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_values)
- val titles = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_entries)
- val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE, items) {
- initialSelection = DirectoryStructure.DEFAULT.value
- dependency = PreferenceKeys.AUTOFILL_ENABLE
- titleRes = R.string.oreo_autofill_preference_directory_structure
- }
- editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) {
- dependency = PreferenceKeys.AUTOFILL_ENABLE
- titleRes = R.string.preference_default_username_title
- summaryProvider = { activity.getString(R.string.preference_default_username_summary) }
- }
- editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) {
- dependency = PreferenceKeys.AUTOFILL_ENABLE
- titleRes = R.string.preference_custom_public_suffixes_title
- summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) }
- textInputHintRes = R.string.preference_custom_public_suffixes_hint
- }
+ override fun provideSettings(builder: PreferenceScreen.Builder) {
+ builder.apply {
+ switch(PreferenceKeys.AUTOFILL_ENABLE) {
+ titleRes = R.string.pref_autofill_enable_title
+ visible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+ defaultValue = isAutofillServiceEnabled
+ onClick {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return@onClick true
+ if (isAutofillServiceEnabled) {
+ activity.autofillManager?.disableAutofillServices()
+ } else {
+ showAutofillDialog(this)
+ }
+ false
}
+ }
+ val values = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_values)
+ val titles = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_entries)
+ val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) }
+ singleChoice(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE, items) {
+ initialSelection = DirectoryStructure.DEFAULT.value
+ dependency = PreferenceKeys.AUTOFILL_ENABLE
+ titleRes = R.string.oreo_autofill_preference_directory_structure
+ }
+ editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) {
+ dependency = PreferenceKeys.AUTOFILL_ENABLE
+ titleRes = R.string.preference_default_username_title
+ summaryProvider = { activity.getString(R.string.preference_default_username_summary) }
+ }
+ editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) {
+ dependency = PreferenceKeys.AUTOFILL_ENABLE
+ titleRes = R.string.preference_custom_public_suffixes_title
+ summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) }
+ textInputHintRes = R.string.preference_custom_public_suffixes_hint
+ }
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/DirectorySelectionActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/DirectorySelectionActivity.kt
index a2d246f1..475d3b5e 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/DirectorySelectionActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/settings/DirectorySelectionActivity.kt
@@ -20,37 +20,38 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class DirectorySelectionActivity : AppCompatActivity() {
- @Suppress("DEPRECATION")
- private val directorySelectAction = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
- if (uri == null) return@registerForActivityResult
-
- 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
-
- d { "Selected repository path is $repoPath" }
-
- if (Environment.getExternalStorageDirectory().path == repoPath) {
- MaterialAlertDialogBuilder(this)
- .setTitle(resources.getString(R.string.sdcard_root_warning_title))
- .setMessage(resources.getString(R.string.sdcard_root_warning_message))
- .setPositiveButton(resources.getString(R.string.sdcard_root_warning_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) }
- setResult(RESULT_OK)
- finish()
+ @Suppress("DEPRECATION")
+ private val directorySelectAction =
+ registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
+ if (uri == null) return@registerForActivityResult
+
+ 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
+
+ d { "Selected repository path is $repoPath" }
+
+ if (Environment.getExternalStorageDirectory().path == repoPath) {
+ MaterialAlertDialogBuilder(this)
+ .setTitle(resources.getString(R.string.sdcard_root_warning_title))
+ .setMessage(resources.getString(R.string.sdcard_root_warning_message))
+ .setPositiveButton(resources.getString(R.string.sdcard_root_warning_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) }
+ setResult(RESULT_OK)
+ finish()
}
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- directorySelectAction.launch(null)
- }
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ directorySelectAction.launch(null)
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt
index 64b9c3f1..39501d52 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/settings/GeneralSettings.kt
@@ -22,83 +22,85 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider {
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
- val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
- val themeItems = themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.APP_THEME, themeItems) {
- initialSelection = activity.resources.getString(R.string.app_theme_def)
- titleRes = R.string.pref_app_theme_title
- }
+ override fun provideSettings(builder: PreferenceScreen.Builder) {
+ builder.apply {
+ val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
+ val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
+ val themeItems = themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
+ singleChoice(PreferenceKeys.APP_THEME, themeItems) {
+ initialSelection = activity.resources.getString(R.string.app_theme_def)
+ titleRes = R.string.pref_app_theme_title
+ }
- val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
- val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
- val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
- singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
- initialSelection = sortValues[0]
- titleRes = R.string.pref_sort_order_title
- }
+ val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
+ val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
+ val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
+ singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
+ initialSelection = sortValues[0]
+ titleRes = R.string.pref_sort_order_title
+ }
- checkBox(PreferenceKeys.FILTER_RECURSIVELY) {
- titleRes = R.string.pref_recursive_filter_title
- summaryRes = R.string.pref_recursive_filter_summary
- defaultValue = true
- }
+ checkBox(PreferenceKeys.FILTER_RECURSIVELY) {
+ titleRes = R.string.pref_recursive_filter_title
+ summaryRes = R.string.pref_recursive_filter_summary
+ defaultValue = true
+ }
- checkBox(PreferenceKeys.SEARCH_ON_START) {
- titleRes = R.string.pref_search_on_start_title
- summaryRes = R.string.pref_search_on_start_summary
- defaultValue = false
- }
+ checkBox(PreferenceKeys.SEARCH_ON_START) {
+ titleRes = R.string.pref_search_on_start_title
+ summaryRes = R.string.pref_search_on_start_summary
+ defaultValue = false
+ }
- checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
- titleRes = R.string.pref_show_hidden_title
- summaryRes = R.string.pref_show_hidden_summary
- defaultValue = false
- }
+ checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
+ titleRes = R.string.pref_show_hidden_title
+ summaryRes = R.string.pref_show_hidden_summary
+ defaultValue = false
+ }
- checkBox(PreferenceKeys.BIOMETRIC_AUTH) {
- titleRes = R.string.pref_biometric_auth_title
- defaultValue = false
- }.apply {
- val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
- if (!canAuthenticate) {
- enabled = false
- checked = false
- summaryRes = R.string.pref_biometric_auth_summary_error
- } else {
- summaryRes = R.string.pref_biometric_auth_summary
- onClick {
- enabled = false
- val isChecked = checked
- activity.sharedPrefs.edit {
- BiometricAuthenticator.authenticate(activity) { result ->
- when (result) {
- is BiometricAuthenticator.Result.Success -> {
- // Apply the changes
- putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked)
- enabled = 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)
- checked = !isChecked
- enabled = true
- }
- }
- }
- }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
- activity.getSystemService<ShortcutManager>()?.apply {
- removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
- }
- }
- false
+ checkBox(PreferenceKeys.BIOMETRIC_AUTH) {
+ titleRes = R.string.pref_biometric_auth_title
+ defaultValue = false
+ }
+ .apply {
+ val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
+ if (!canAuthenticate) {
+ enabled = false
+ checked = false
+ summaryRes = R.string.pref_biometric_auth_summary_error
+ } else {
+ summaryRes = R.string.pref_biometric_auth_summary
+ onClick {
+ enabled = false
+ val isChecked = checked
+ activity.sharedPrefs.edit {
+ BiometricAuthenticator.authenticate(activity) { result ->
+ when (result) {
+ is BiometricAuthenticator.Result.Success -> {
+ // Apply the changes
+ putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked)
+ enabled = 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)
+ checked = !isChecked
+ enabled = true
}
+ }
+ }
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ activity.getSystemService<ShortcutManager>()?.apply {
+ removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
}
+ }
+ false
}
+ }
}
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt
index faed1c3c..8921317b 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/settings/MiscSettings.kt
@@ -23,54 +23,59 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class MiscSettings(activity: FragmentActivity) : SettingsProvider {
- private val storeExportAction = activity.registerForActivityResult(object : ActivityResultContracts.OpenDocumentTree() {
+ private val storeExportAction =
+ activity.registerForActivityResult(
+ object : ActivityResultContracts.OpenDocumentTree() {
override fun createIntent(context: Context, input: Uri?): Intent {
- return super.createIntent(context, input).apply {
- flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
- Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
- Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
- Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
- }
+ return super.createIntent(context, input).apply {
+ flags =
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or
+ Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
+ Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
+ Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
+ }
}
- }) { uri: Uri? ->
- if (uri == null) return@registerForActivityResult
- val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
+ }
+ ) { uri: Uri? ->
+ if (uri == null) return@registerForActivityResult
+ val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
- if (targetDirectory != null) {
- val service = Intent(activity.applicationContext, PasswordExportService::class.java).apply {
- action = PasswordExportService.ACTION_EXPORT_PASSWORD
- putExtra("uri", uri)
- }
+ if (targetDirectory != null) {
+ val service =
+ Intent(activity.applicationContext, PasswordExportService::class.java).apply {
+ action = PasswordExportService.ACTION_EXPORT_PASSWORD
+ putExtra("uri", uri)
+ }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- activity.startForegroundService(service)
- } else {
- activity.startService(service)
- }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ activity.startForegroundService(service)
+ } else {
+ activity.startService(service)
}
+ }
}
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- pref(PreferenceKeys.EXPORT_PASSWORDS) {
- titleRes = R.string.prefs_export_passwords_title
- summaryRes = R.string.prefs_export_passwords_summary
- onClick {
- storeExportAction.launch(null)
- true
- }
- }
- checkBox(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) {
- defaultValue = false
- titleRes = R.string.pref_clear_clipboard_title
- summaryRes = R.string.pref_clear_clipboard_summary
- }
- checkBox(PreferenceKeys.ENABLE_DEBUG_LOGGING) {
- defaultValue = false
- titleRes = R.string.pref_debug_logging_title
- summaryRes = R.string.pref_debug_logging_summary
- visible = !BuildConfig.DEBUG
- }
+ override fun provideSettings(builder: PreferenceScreen.Builder) {
+ builder.apply {
+ pref(PreferenceKeys.EXPORT_PASSWORDS) {
+ titleRes = R.string.prefs_export_passwords_title
+ summaryRes = R.string.prefs_export_passwords_summary
+ onClick {
+ storeExportAction.launch(null)
+ true
}
+ }
+ checkBox(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) {
+ defaultValue = false
+ titleRes = R.string.pref_clear_clipboard_title
+ summaryRes = R.string.pref_clear_clipboard_summary
+ }
+ checkBox(PreferenceKeys.ENABLE_DEBUG_LOGGING) {
+ defaultValue = false
+ titleRes = R.string.pref_debug_logging_title
+ summaryRes = R.string.pref_debug_logging_summary
+ visible = !BuildConfig.DEBUG
+ }
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt
index 98748584..ca72e4b1 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/settings/PasswordSettings.kt
@@ -29,85 +29,90 @@ import java.io.File
class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider {
- private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs }
- private val storeCustomXkpwdDictionaryAction = activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
- if (uri == null) return@registerForActivityResult
+ private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs }
+ private val storeCustomXkpwdDictionaryAction =
+ activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri == null) return@registerForActivityResult
- Toast.makeText(
- activity,
- activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path),
- Toast.LENGTH_SHORT
- ).show()
+ Toast.makeText(
+ activity,
+ activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path),
+ Toast.LENGTH_SHORT
+ )
+ .show()
- sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) }
+ sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) }
- val inputStream = activity.contentResolver.openInputStream(uri)
- val customDictFile = File(activity.filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream()
- inputStream?.copyTo(customDictFile, 1024)
- inputStream?.close()
- customDictFile.close()
+ val inputStream = activity.contentResolver.openInputStream(uri)
+ val customDictFile = File(activity.filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream()
+ inputStream?.copyTo(customDictFile, 1024)
+ inputStream?.close()
+ customDictFile.close()
}
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- val customDictPref = CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply {
- titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title
- summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off
- summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on
- visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
- onCheckedChange {
- requestRebind()
- true
- }
- }
- val customDictPathPref = Preference(PreferenceKeys.PREF_KEY_CUSTOM_DICT).apply {
- dependency = PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT
- titleRes = R.string.pref_xkpwgen_custom_dict_picker_title
- summary = sharedPrefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
- ?: activity.resources.getString(R.string.pref_xkpwgen_custom_dict_picker_summary)
- visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
- onClick {
- storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*"))
- true
- }
- }
- val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
- val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels)
- val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) }
- singleChoice(
- PreferenceKeys.PREF_KEY_PWGEN_TYPE,
- items,
- ) {
- initialSelection = "classic"
- titleRes = R.string.pref_password_generator_type_title
- onSelectionChange { selection ->
- val xkpasswdEnabled = selection == "xkpasswd"
- customDictPathPref.visible = xkpasswdEnabled
- customDictPref.visible = xkpasswdEnabled
- customDictPref.requestRebind()
- customDictPathPref.requestRebind()
- true
- }
- }
- // We initialize them early and add them manually to be able to manually force a rebind
- // when the password generator type is changed.
- addPreferenceItem(customDictPref)
- addPreferenceItem(customDictPathPref)
- editText(PreferenceKeys.GENERAL_SHOW_TIME) {
- titleRes = R.string.pref_clipboard_timeout_title
- summaryProvider = { activity.getString(R.string.pref_clipboard_timeout_summary) }
- textInputType = InputType.TYPE_CLASS_NUMBER
- }
- checkBox(PreferenceKeys.SHOW_PASSWORD) {
- titleRes = R.string.show_password_pref_title
- summaryRes = R.string.show_password_pref_summary
- defaultValue = true
- }
- checkBox(PreferenceKeys.COPY_ON_DECRYPT) {
- titleRes = R.string.pref_copy_title
- summaryRes = R.string.pref_copy_summary
- defaultValue = false
- }
+ override fun provideSettings(builder: PreferenceScreen.Builder) {
+ builder.apply {
+ val customDictPref =
+ CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply {
+ titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title
+ summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off
+ summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on
+ visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
+ onCheckedChange {
+ requestRebind()
+ true
+ }
}
+ val customDictPathPref =
+ Preference(PreferenceKeys.PREF_KEY_CUSTOM_DICT).apply {
+ dependency = PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT
+ titleRes = R.string.pref_xkpwgen_custom_dict_picker_title
+ summary =
+ sharedPrefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
+ ?: activity.resources.getString(R.string.pref_xkpwgen_custom_dict_picker_summary)
+ visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
+ onClick {
+ storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*"))
+ true
+ }
+ }
+ val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
+ val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels)
+ val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) }
+ singleChoice(
+ PreferenceKeys.PREF_KEY_PWGEN_TYPE,
+ items,
+ ) {
+ initialSelection = "classic"
+ titleRes = R.string.pref_password_generator_type_title
+ onSelectionChange { selection ->
+ val xkpasswdEnabled = selection == "xkpasswd"
+ customDictPathPref.visible = xkpasswdEnabled
+ customDictPref.visible = xkpasswdEnabled
+ customDictPref.requestRebind()
+ customDictPathPref.requestRebind()
+ true
+ }
+ }
+ // We initialize them early and add them manually to be able to manually force a rebind
+ // when the password generator type is changed.
+ addPreferenceItem(customDictPref)
+ addPreferenceItem(customDictPathPref)
+ editText(PreferenceKeys.GENERAL_SHOW_TIME) {
+ titleRes = R.string.pref_clipboard_timeout_title
+ summaryProvider = { activity.getString(R.string.pref_clipboard_timeout_summary) }
+ textInputType = InputType.TYPE_CLASS_NUMBER
+ }
+ checkBox(PreferenceKeys.SHOW_PASSWORD) {
+ titleRes = R.string.show_password_pref_title
+ summaryRes = R.string.show_password_pref_summary
+ defaultValue = true
+ }
+ checkBox(PreferenceKeys.COPY_ON_DECRYPT) {
+ titleRes = R.string.pref_copy_title
+ summaryRes = R.string.pref_copy_summary
+ defaultValue = false
+ }
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt
index afd54298..3e99c890 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/settings/RepositorySettings.kt
@@ -37,168 +37,165 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider {
- private val encryptedPreferences by lazy(LazyThreadSafetyMode.NONE) { activity.getEncryptedGitPrefs() }
+ private val encryptedPreferences by lazy(LazyThreadSafetyMode.NONE) { activity.getEncryptedGitPrefs() }
- private fun <T : FragmentActivity> launchActivity(clazz: Class<T>) {
- activity.startActivity(Intent(activity, clazz))
- }
+ private fun <T : FragmentActivity> launchActivity(clazz: Class<T>) {
+ activity.startActivity(Intent(activity, clazz))
+ }
- private fun selectExternalGitRepository() {
- MaterialAlertDialogBuilder(activity)
- .setTitle(activity.resources.getString(R.string.external_repository_dialog_title))
- .setMessage(activity.resources.getString(R.string.external_repository_dialog_text))
- .setPositiveButton(R.string.dialog_ok) { _, _ ->
- launchActivity(DirectorySelectionActivity::class.java)
- }
- .setNegativeButton(R.string.dialog_cancel, null)
- .show()
- }
+ private fun selectExternalGitRepository() {
+ MaterialAlertDialogBuilder(activity)
+ .setTitle(activity.resources.getString(R.string.external_repository_dialog_title))
+ .setMessage(activity.resources.getString(R.string.external_repository_dialog_text))
+ .setPositiveButton(R.string.dialog_ok) { _, _ -> launchActivity(DirectorySelectionActivity::class.java) }
+ .setNegativeButton(R.string.dialog_cancel, null)
+ .show()
+ }
- override fun provideSettings(builder: PreferenceScreen.Builder) {
- builder.apply {
- checkBox(PreferenceKeys.REBASE_ON_PULL) {
- titleRes = R.string.pref_rebase_on_pull_title
- summaryRes = R.string.pref_rebase_on_pull_summary
- summaryOnRes = R.string.pref_rebase_on_pull_summary_on
- defaultValue = true
- }
- pref(PreferenceKeys.GIT_SERVER_INFO) {
- titleRes = R.string.pref_edit_git_server_settings
- visible = PasswordRepository.isGitRepo()
- onClick {
- launchActivity(GitServerConfigActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.PROXY_SETTINGS) {
- titleRes = R.string.pref_edit_proxy_settings
- visible = GitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo()
- onClick {
- launchActivity(ProxySelectorActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.GIT_CONFIG) {
- titleRes = R.string.pref_edit_git_config
- visible = PasswordRepository.isGitRepo()
- onClick {
- launchActivity(GitConfigActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.SSH_KEY) {
- titleRes = R.string.pref_import_ssh_key_title
- visible = PasswordRepository.isGitRepo()
- onClick {
- launchActivity(SshKeyImportActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.SSH_KEYGEN) {
- titleRes = R.string.pref_ssh_keygen_title
- onClick {
- launchActivity(SshKeyGenActivity::class.java)
- true
- }
- }
- pref(PreferenceKeys.SSH_SEE_KEY) {
- titleRes = R.string.pref_ssh_see_key_title
- visible = PasswordRepository.isGitRepo()
- onClick {
- ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
- true
- }
- }
- pref(PreferenceKeys.CLEAR_SAVED_PASS) {
- fun Preference.updatePref() {
- val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
- val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
- if (sshPass == null && httpsPass == null) {
- visible = false
- return
- }
- when {
- httpsPass != null -> titleRes = R.string.clear_saved_passphrase_https
- sshPass != null -> titleRes = R.string.clear_saved_passphrase_ssh
- }
- visible = true
- requestRebind()
- }
- onClick {
- updatePref()
- true
- }
- updatePref()
- }
- pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
- titleRes = R.string.pref_title_openkeystore_clear_keyid
- visible = activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty()
- ?: false
- onClick {
- activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
- visible = false
- true
+ override fun provideSettings(builder: PreferenceScreen.Builder) {
+ builder.apply {
+ checkBox(PreferenceKeys.REBASE_ON_PULL) {
+ titleRes = R.string.pref_rebase_on_pull_title
+ summaryRes = R.string.pref_rebase_on_pull_summary
+ summaryOnRes = R.string.pref_rebase_on_pull_summary_on
+ defaultValue = true
+ }
+ pref(PreferenceKeys.GIT_SERVER_INFO) {
+ titleRes = R.string.pref_edit_git_server_settings
+ visible = PasswordRepository.isGitRepo()
+ onClick {
+ launchActivity(GitServerConfigActivity::class.java)
+ true
+ }
+ }
+ pref(PreferenceKeys.PROXY_SETTINGS) {
+ titleRes = R.string.pref_edit_proxy_settings
+ visible = GitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo()
+ onClick {
+ launchActivity(ProxySelectorActivity::class.java)
+ true
+ }
+ }
+ pref(PreferenceKeys.GIT_CONFIG) {
+ titleRes = R.string.pref_edit_git_config
+ visible = PasswordRepository.isGitRepo()
+ onClick {
+ launchActivity(GitConfigActivity::class.java)
+ true
+ }
+ }
+ pref(PreferenceKeys.SSH_KEY) {
+ titleRes = R.string.pref_import_ssh_key_title
+ visible = PasswordRepository.isGitRepo()
+ onClick {
+ launchActivity(SshKeyImportActivity::class.java)
+ true
+ }
+ }
+ pref(PreferenceKeys.SSH_KEYGEN) {
+ titleRes = R.string.pref_ssh_keygen_title
+ onClick {
+ launchActivity(SshKeyGenActivity::class.java)
+ true
+ }
+ }
+ pref(PreferenceKeys.SSH_SEE_KEY) {
+ titleRes = R.string.pref_ssh_see_key_title
+ visible = PasswordRepository.isGitRepo()
+ onClick {
+ ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
+ true
+ }
+ }
+ pref(PreferenceKeys.CLEAR_SAVED_PASS) {
+ fun Preference.updatePref() {
+ val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
+ val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
+ if (sshPass == null && httpsPass == null) {
+ visible = false
+ return
+ }
+ when {
+ httpsPass != null -> titleRes = R.string.clear_saved_passphrase_https
+ sshPass != null -> titleRes = R.string.clear_saved_passphrase_ssh
+ }
+ visible = true
+ requestRebind()
+ }
+ onClick {
+ updatePref()
+ true
+ }
+ updatePref()
+ }
+ pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
+ titleRes = R.string.pref_title_openkeystore_clear_keyid
+ visible = activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() ?: false
+ onClick {
+ activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
+ visible = false
+ true
+ }
+ }
+ val deleteRepoPref =
+ pref(PreferenceKeys.GIT_DELETE_REPO) {
+ titleRes = R.string.pref_git_delete_repo_title
+ summaryRes = R.string.pref_git_delete_repo_summary
+ visible = !activity.sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
+ onClick {
+ val repoDir = PasswordRepository.getRepositoryDirectory()
+ MaterialAlertDialogBuilder(activity)
+ .setTitle(R.string.pref_dialog_delete_title)
+ .setMessage(activity.getString(R.string.dialog_delete_msg, repoDir))
+ .setCancelable(false)
+ .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ ->
+ runCatching {
+ PasswordRepository.getRepositoryDirectory().deleteRecursively()
+ PasswordRepository.closeRepository()
}
- }
- val deleteRepoPref = pref(PreferenceKeys.GIT_DELETE_REPO) {
- titleRes = R.string.pref_git_delete_repo_title
- summaryRes = R.string.pref_git_delete_repo_summary
- visible = !activity.sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
- onClick {
- val repoDir = PasswordRepository.getRepositoryDirectory()
- MaterialAlertDialogBuilder(activity)
- .setTitle(R.string.pref_dialog_delete_title)
- .setMessage(activity.getString(R.string.dialog_delete_msg, repoDir))
- .setCancelable(false)
- .setPositiveButton(R.string.dialog_delete) { dialogInterface, _ ->
- runCatching {
- PasswordRepository.getRepositoryDirectory().deleteRecursively()
- PasswordRepository.closeRepository()
- }.onFailure {
- it.message?.let { message ->
- activity.snackbar(message = message)
- }
- }
+ .onFailure { it.message?.let { message -> activity.snackbar(message = message) } }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
- activity.getSystemService<ShortcutManager>()?.apply {
- removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
- }
- }
- activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
- dialogInterface.cancel()
- activity.finish()
- }
- .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> run { dialogInterface.cancel() } }
- .show()
- true
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
+ activity.getSystemService<ShortcutManager>()?.apply {
+ removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
+ }
}
- }
- checkBox(PreferenceKeys.GIT_EXTERNAL) {
- titleRes = R.string.pref_external_repository_title
- summaryRes = R.string.pref_external_repository_summary
- onCheckedChange { checked ->
- deleteRepoPref.visible = !checked
- deleteRepoPref.requestRebind()
- PasswordRepository.closeRepository()
- activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) }
- true
- }
- }
- pref(PreferenceKeys.GIT_EXTERNAL_REPO) {
- val externalRepo = activity.sharedPrefs.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
- if (externalRepo != null) {
- summary = externalRepo
- } else {
- summaryRes = R.string.pref_select_external_repository_summary_no_repo_selected
- }
- titleRes = R.string.pref_select_external_repository_title
- dependency = PreferenceKeys.GIT_EXTERNAL
- onClick {
- selectExternalGitRepository()
- true
- }
- }
+ activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
+ dialogInterface.cancel()
+ activity.finish()
+ }
+ .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ ->
+ run { dialogInterface.cancel() }
+ }
+ .show()
+ true
+ }
+ }
+ checkBox(PreferenceKeys.GIT_EXTERNAL) {
+ titleRes = R.string.pref_external_repository_title
+ summaryRes = R.string.pref_external_repository_summary
+ onCheckedChange { checked ->
+ deleteRepoPref.visible = !checked
+ deleteRepoPref.requestRebind()
+ PasswordRepository.closeRepository()
+ activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) }
+ true
+ }
+ }
+ pref(PreferenceKeys.GIT_EXTERNAL_REPO) {
+ val externalRepo = activity.sharedPrefs.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
+ if (externalRepo != null) {
+ summary = externalRepo
+ } else {
+ summaryRes = R.string.pref_select_external_repository_summary_no_repo_selected
+ }
+ titleRes = R.string.pref_select_external_repository_title
+ dependency = PreferenceKeys.GIT_EXTERNAL
+ onClick {
+ selectExternalGitRepository()
+ true
}
+ }
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt
index d593bd21..ceb6599b 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsActivity.kt
@@ -17,77 +17,79 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
class SettingsActivity : AppCompatActivity() {
- private val miscSettings = MiscSettings(this)
- private val autofillSettings = AutofillSettings(this)
- private val passwordSettings = PasswordSettings(this)
- private val repositorySettings = RepositorySettings(this)
- private val generalSettings = GeneralSettings(this)
+ private val miscSettings = MiscSettings(this)
+ private val autofillSettings = AutofillSettings(this)
+ private val passwordSettings = PasswordSettings(this)
+ private val repositorySettings = RepositorySettings(this)
+ private val generalSettings = GeneralSettings(this)
- private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
- private val preferencesAdapter: PreferencesAdapter
- get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
+ private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
+ private val preferencesAdapter: PreferencesAdapter
+ get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- setContentView(binding.root)
- val screen = screen(this) {
- subScreen {
- titleRes = R.string.pref_category_general_title
- iconRes = R.drawable.app_settings_alt_24px
- generalSettings.provideSettings(this)
- }
- subScreen {
- titleRes = R.string.pref_category_autofill_title
- iconRes = R.drawable.ic_wysiwyg_24px
- autofillSettings.provideSettings(this)
- }
- subScreen {
- titleRes = R.string.pref_category_passwords_title
- iconRes = R.drawable.ic_lock_open_24px
- passwordSettings.provideSettings(this)
- }
- subScreen {
- titleRes = R.string.pref_category_repository_title
- iconRes = R.drawable.ic_call_merge_24px
- repositorySettings.provideSettings(this)
- }
- subScreen {
- titleRes = R.string.pref_category_misc_title
- iconRes = R.drawable.ic_miscellaneous_services_24px
- miscSettings.provideSettings(this)
- }
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(binding.root)
+ val screen =
+ screen(this) {
+ subScreen {
+ titleRes = R.string.pref_category_general_title
+ iconRes = R.drawable.app_settings_alt_24px
+ generalSettings.provideSettings(this)
}
- val adapter = PreferencesAdapter(screen)
- adapter.onScreenChangeListener = PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
- supportActionBar?.title = if (!entering) {
- getString(R.string.action_settings)
- } else {
- getString(subScreen.titleRes)
- }
+ subScreen {
+ titleRes = R.string.pref_category_autofill_title
+ iconRes = R.drawable.ic_wysiwyg_24px
+ autofillSettings.provideSettings(this)
}
- savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter")
- ?.let(adapter::loadSavedState)
- binding.preferenceRecyclerView.adapter = adapter
- }
+ subScreen {
+ titleRes = R.string.pref_category_passwords_title
+ iconRes = R.drawable.ic_lock_open_24px
+ passwordSettings.provideSettings(this)
+ }
+ subScreen {
+ titleRes = R.string.pref_category_repository_title
+ iconRes = R.drawable.ic_call_merge_24px
+ repositorySettings.provideSettings(this)
+ }
+ subScreen {
+ titleRes = R.string.pref_category_misc_title
+ iconRes = R.drawable.ic_miscellaneous_services_24px
+ miscSettings.provideSettings(this)
+ }
+ }
+ val adapter = PreferencesAdapter(screen)
+ adapter.onScreenChangeListener =
+ PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
+ supportActionBar?.title =
+ if (!entering) {
+ getString(R.string.action_settings)
+ } else {
+ getString(subScreen.titleRes)
+ }
+ }
+ savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter")?.let(adapter::loadSavedState)
+ binding.preferenceRecyclerView.adapter = adapter
+ }
- override fun onSaveInstanceState(outState: Bundle) {
- super.onSaveInstanceState(outState)
- outState.putParcelable("adapter", preferencesAdapter.getSavedState())
- }
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ outState.putParcelable("adapter", preferencesAdapter.getSavedState())
+ }
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> if (!preferencesAdapter.goBack()) {
- super.onOptionsItemSelected(item)
- } else {
- true
- }
- else -> super.onOptionsItemSelected(item)
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home ->
+ if (!preferencesAdapter.goBack()) {
+ super.onOptionsItemSelected(item)
+ } else {
+ true
}
+ else -> super.onOptionsItemSelected(item)
}
+ }
- override fun onBackPressed() {
- if (!preferencesAdapter.goBack())
- super.onBackPressed()
- }
+ override fun onBackPressed() {
+ if (!preferencesAdapter.goBack()) super.onBackPressed()
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt b/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt
index 3599703e..61b11064 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/settings/SettingsProvider.kt
@@ -7,13 +7,9 @@ package dev.msfjarvis.aps.ui.settings
import de.Maxr1998.modernpreferences.PreferenceScreen
-/**
- * Used to generate a uniform API for all settings UI classes.
- */
+/** Used to generate a uniform API for all settings UI classes. */
interface SettingsProvider {
- /**
- * Inserts the settings items for the class into the given [builder].
- */
- fun provideSettings(builder: PreferenceScreen.Builder)
+ /** Inserts the settings items for the class into the given [builder]. */
+ fun provideSettings(builder: PreferenceScreen.Builder)
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt
index 42346ccc..ee54febe 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/ShowSshKeyFragment.kt
@@ -14,25 +14,24 @@ 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()
- }
+ 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
index fb977cd1..c2025538 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyGenActivity.kt
@@ -30,135 +30,122 @@ 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)
- }),
+ 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)
+ 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()
- }
- }
+ 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")
}
- 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
- })
- }
+ 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
+ )
}
+ }
+ keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure
+ keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled
}
+ }
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- onBackPressed()
- true
- }
- else -> super.onOptionsItemSelected(item)
- }
+ override fun onOptionsItemSelected(item: MenuItem): Boolean {
+ return when (item.itemId) {
+ android.R.id.home -> {
+ onBackPressed()
+ true
+ }
+ else -> super.onOptionsItemSelected(item)
}
+ }
- private suspend fun generate() {
- binding.generate.apply {
- text = getString(R.string.ssh_key_gen_generating_progress)
- isEnabled = false
- }
- binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
- val result = runCatching {
- withContext(Dispatchers.IO) {
- val requireAuthentication = binding.keyRequireAuthentication.isChecked
- if (requireAuthentication) {
- val result = withContext(Dispatchers.Main) {
- suspendCoroutine<BiometricAuthenticator.Result> { 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)
+ 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<BiometricAuthenticator.Result> { 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))
}
- 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)) { _, _ ->
- setResult(RESULT_OK)
- finish()
- }
- .show()
- },
- )
- hideKeyboard()
+ 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)) { _, _ ->
+ setResult(RESULT_OK)
+ finish()
+ }
+ .show()
+ },
+ )
+ hideKeyboard()
+ }
- private fun hideKeyboard() {
- val imm = getSystemService<InputMethodManager>() ?: return
- var view = currentFocus
- if (view == null) {
- view = View(this)
- }
- imm.hideSoftInputFromWindow(view.windowToken, 0)
+ private fun hideKeyboard() {
+ val imm = getSystemService<InputMethodManager>() ?: return
+ var view = currentFocus
+ if (view == null) {
+ view = View(this)
}
+ imm.hideSoftInputFromWindow(view.windowToken, 0)
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt
index 2d482d3c..bf9f6eda 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/sshkeygen/SshKeyImportActivity.kt
@@ -18,44 +18,44 @@ import dev.msfjarvis.aps.util.git.sshj.SshKey
class SshKeyImportActivity : AppCompatActivity() {
- private val sshKeyImportAction = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
- if (uri == null) {
- finish()
- return@registerForActivityResult
- }
- runCatching {
- SshKey.import(uri)
- Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show()
- setResult(RESULT_OK)
- finish()
- }.onFailure { e ->
- MaterialAlertDialogBuilder(this)
- .setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
- .setMessage(e.message)
- .setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() }
- .show()
+ private val sshKeyImportAction =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
+ if (uri == null) {
+ finish()
+ return@registerForActivityResult
+ }
+ runCatching {
+ SshKey.import(uri)
+ Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show()
+ setResult(RESULT_OK)
+ finish()
+ }
+ .onFailure { e ->
+ MaterialAlertDialogBuilder(this)
+ .setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
+ .setMessage(e.message)
+ .setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() }
+ .show()
}
}
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- if (SshKey.exists) {
- MaterialAlertDialogBuilder(this).run {
- setTitle(R.string.ssh_keygen_existing_title)
- setMessage(R.string.ssh_keygen_existing_message)
- setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
- importSshKey()
- }
- setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
- setOnCancelListener { finish() }
- show()
- }
- } else {
- importSshKey()
- }
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (SshKey.exists) {
+ MaterialAlertDialogBuilder(this).run {
+ setTitle(R.string.ssh_keygen_existing_title)
+ setMessage(R.string.ssh_keygen_existing_message)
+ setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() }
+ setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
+ setOnCancelListener { finish() }
+ show()
+ }
+ } else {
+ importSshKey()
}
+ }
- private fun importSshKey() {
- sshKeyImportAction.launch(arrayOf("*/*"))
- }
+ private fun importSshKey() {
+ sshKeyImportAction.launch(arrayOf("*/*"))
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt b/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt
index 590b376f..494a9ed7 100644
--- a/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/ui/util/OnOffItemAnimator.kt
@@ -9,63 +9,63 @@ 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
+ var isEnabled: Boolean = true
+ set(value) {
+ // Defer update until no animation is running anymore.
+ isRunning { field = value }
}
- override fun animateAppearance(
- viewHolder: RecyclerView.ViewHolder,
- preLayoutInfo: ItemHolderInfo?,
- postLayoutInfo: ItemHolderInfo
- ): Boolean {
- return if (isEnabled) {
- super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo)
- } else {
- dontAnimate(viewHolder)
- }
+ 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 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 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)
- }
+ 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
index eba61d77..c3655298 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/auth/BiometricAuthenticator.kt
@@ -18,61 +18,69 @@ import dev.msfjarvis.aps.R
object BiometricAuthenticator {
- private const val TAG = "BiometricAuthenticator"
- private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
+ 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
- }
+ 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 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))
- })
- }
+ fun canAuthenticate(activity: FragmentActivity): Boolean {
+ return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
+ }
- override fun onAuthenticationFailed() {
- super.onAuthenticationFailed()
- callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error)))
+ 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 onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
- super.onAuthenticationSucceeded(result)
- callback(Result.Success(result.cryptoObject))
- }
+ override fun onAuthenticationFailed() {
+ super.onAuthenticationFailed()
+ callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error)))
}
- val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true
- if (canAuthenticate(activity) || deviceHasKeyguard) {
- val promptInfo = BiometricPrompt.PromptInfo.Builder()
- .setTitle(activity.getString(dialogTitleRes))
- .setAllowedAuthenticators(validAuthenticators)
- .build()
- BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback).authenticate(promptInfo)
- } else {
- callback(Result.HardwareUnavailableOrDisabled)
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ super.onAuthenticationSucceeded(result)
+ callback(Result.Success(result.cryptoObject))
}
+ }
+ val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true
+ if (canAuthenticate(activity) || deviceHasKeyguard) {
+ val promptInfo =
+ BiometricPrompt.PromptInfo.Builder()
+ .setTitle(activity.getString(dialogTitleRes))
+ .setAllowedAuthenticators(validAuthenticators)
+ .build()
+ BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback)
+ .authenticate(promptInfo)
+ } else {
+ callback(Result.HardwareUnavailableOrDisabled)
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
index fa2accdb..a39db31d 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
@@ -27,163 +27,166 @@ import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
import java.io.File
-/**
- * Implements [AutofillResponseBuilder]'s methods for API 30 and above
- */
+/** 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 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 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 makePublisherChangedResponse(
- context: Context,
- inlineSuggestionsRequest: InlineSuggestionsRequest?,
- publisherChangedException: AutofillPublisherChangedException
- ): FillResponse {
- val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull()
- return FillResponse.Builder().run {
- addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec))
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
+ }
+
+ private fun makeFillResponse(
+ context: Context,
+ inlineSuggestionsRequest: InlineSuggestionsRequest?,
+ matchedFiles: List<File>
+ ): FillResponse? {
+ var datasetCount = 0
+ val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
+ return FillResponse.Builder().run {
+ for (file in matchedFiles) {
+ makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
}
+ }
+ makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ if (datasetCount == 0) return null
+ setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
+ makeSaveInfo()?.let { setSaveInfo(it) }
+ setClientState(clientState)
+ setIgnoredIds(*ignoredIds.toTypedArray())
+ build()
}
-
- private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List<File>): FillResponse? {
- var datasetCount = 0
- val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
- return FillResponse.Builder().run {
- for (file in matchedFiles) {
- makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- }
- makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- if (datasetCount == 0) return null
- setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
- makeSaveInfo()?.let { setSaveInfo(it) }
- setClientState(clientState)
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
+ }
+
+ // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
+ // See:
+ // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
+ private fun makeSaveInfo(): SaveInfo? {
+ if (!canBeSaved) return null
+ check(saveFlags != null)
+ val idsToSave = scenario.fieldsToSave.toTypedArray()
+ if (idsToSave.isEmpty()) return null
+ var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
+ if (scenario.hasUsername) {
+ saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
}
-
- // 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))
- }
- )
+ 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
index 955aa047..418843f6 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt
@@ -21,173 +21,165 @@ 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)
+ 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)
+ 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
- }
+ 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") {
+ Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
- init {
- require(formOrigin is FormOrigin.App)
- }
+ init {
+ require(formOrigin is FormOrigin.App)
+ }
}
-/**
- * Manages "matches", i.e., associations between apps or websites and Password Store entries.
- */
+/** 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
- }
- }
- }
+ 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.
- }
+ private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
+ if (formOrigin is FormOrigin.App) {
+ val packageName = formOrigin.identifier
+ val certificatesHash = computeCertificatesHash(context, packageName)
+ context.autofillAppMatches.edit { putString(tokenKey(formOrigin), certificatesHash) }
+ }
+ // We don't need to store a hash for FormOrigin.Web since it can only originate from
+ // browsers we trust to verify the origin.
+ }
- /**
- * Get all Password Store entries that have already been associated with [formOrigin] by the
- * user.
- *
- * If [formOrigin] represents an app and that app's certificates have changed since the
- * first time the user associated an entry with it, an [AutofillPublisherChangedException]
- * will be thrown.
- */
- fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> {
- if (hasFormOriginHashChanged(context, formOrigin)) {
- return Err(AutofillPublisherChangedException(formOrigin))
- }
- val matchPreferences = context.matchPreferences(formOrigin)
- val matchedFiles =
- matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
- return Ok(matchedFiles.filter { it.exists() }.also { validFiles ->
- matchPreferences.edit {
- putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet())
- }
- })
+ /**
+ * Get all Password Store entries that have already been associated with [formOrigin] by the
+ * user.
+ *
+ * If [formOrigin] represents an app and that app's certificates have changed since the first
+ * time the user associated an entry with it, an [AutofillPublisherChangedException] will be
+ * thrown.
+ */
+ fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> {
+ if (hasFormOriginHashChanged(context, formOrigin)) {
+ return Err(AutofillPublisherChangedException(formOrigin))
+ }
+ val matchPreferences = context.matchPreferences(formOrigin)
+ val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
+ return Ok(
+ matchedFiles.filter { it.exists() }.also { validFiles ->
+ matchPreferences.edit { putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) }
}
+ )
+ }
- fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
- context.matchPreferences(formOrigin).edit {
- remove(matchesKey(formOrigin))
- if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
- }
- }
+ 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" }
- }
+ /**
+ * 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<File, File> = emptyMap(), delete: Collection<File> = emptyList()) {
- val deletePathList = delete.map { it.absolutePath }
- val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }
- .mapKeys { it.key.absolutePath }
- for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
- for ((key, value) in prefs.all) {
- if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
- // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
- // created with `putStringSet`.
- @Suppress("UNCHECKED_CAST")
- val oldMatches = value as? Set<String>
- if (oldMatches == null) {
- 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) }
- }
- }
+ /**
+ * Goes through all existing matches and updates their associated entries by using [moveFromTo]
+ * as a lookup table and deleting the matches for files in [delete].
+ */
+ fun updateMatches(
+ context: Context,
+ moveFromTo: Map<File, File> = emptyMap(),
+ delete: Collection<File> = emptyList()
+ ) {
+ val deletePathList = delete.map { it.absolutePath }
+ val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }.mapKeys { it.key.absolutePath }
+ for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
+ for ((key, value) in prefs.all) {
+ if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
+ // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
+ // created with `putStringSet`.
+ @Suppress("UNCHECKED_CAST") val oldMatches = value as? Set<String>
+ if (oldMatches == null) {
+ 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
index b258e5df..6e1fe464 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt
@@ -17,125 +17,128 @@ import java.io.File
import java.nio.file.Paths
enum class DirectoryStructure(val value: String) {
- EncryptedUsername("encrypted_username"),
- FileBased("file"),
- DirectoryBased("directory");
+ 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 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 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 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
+ /**
+ * 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()
+ @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"
+ fun getSaveFileName(username: String?, identifier: String) =
+ when (this) {
+ EncryptedUsername -> identifier
+ FileBased -> username
+ DirectoryBased -> "password"
}
- companion object {
+ companion object {
- val DEFAULT = FileBased
+ val DEFAULT = FileBased
- private val reverseMap = values().associateBy { it.value }
- fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
- }
+ private val reverseMap = values().associateBy { it.value }
+ fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
+ }
}
object AutofillPreferences {
- fun directoryStructure(context: Context): DirectoryStructure {
- val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
- return DirectoryStructure.fromValue(value)
- }
+ fun directoryStructure(context: Context): DirectoryStructure {
+ val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
+ return DirectoryStructure.fromValue(value)
+ }
- fun credentialsFromStoreEntry(
- context: Context,
- file: File,
- entry: PasswordEntry,
- directoryStructure: DirectoryStructure
- ): Credentials {
- // Always give priority to a username stored in the encrypted extras
- val username = entry.username
- ?: directoryStructure.getUsernameFor(file)
- ?: context.getDefaultUsername()
- return Credentials(username, entry.password, entry.calculateTotpCode())
- }
+ 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
index d5e16a26..d8126438 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
@@ -30,176 +30,178 @@ 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 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 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 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()
}
-
- 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)
+ }
+
+ // 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
}
-
- 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)
+ return SaveInfo.Builder(saveDataTypes, idsToSave).run {
+ setFlags(saveFlags)
+ build()
}
-
- private fun makePublisherChangedResponse(
- context: Context,
- publisherChangedException: AutofillPublisherChangedException
- ): FillResponse {
- return FillResponse.Builder().run {
- addDataset(makePublisherChangedDataset(context, publisherChangedException))
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
+ }
+
+ private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
+ var datasetCount = 0
+ return FillResponse.Builder().run {
+ for (file in matchedFiles) {
+ makeMatchDataset(context, file)?.let {
+ datasetCount++
+ addDataset(it)
}
+ }
+ makeGenerateDataset(context)?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeFillOtpFromSmsDataset(context)?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeSearchDataset(context)?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ if (datasetCount == 0) return null
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ 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
+ }
+
+ /** 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))
}
- return SaveInfo.Builder(saveDataTypes, idsToSave).run {
- setFlags(saveFlags)
- build()
- }
- }
+ )
+ }
- private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
- var datasetCount = 0
- return FillResponse.Builder().run {
- for (file in matchedFiles) {
- makeMatchDataset(context, file)?.let {
- datasetCount++
- addDataset(it)
- }
- }
- makeGenerateDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- makeFillOtpFromSmsDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- makeSearchDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- if (datasetCount == 0) return null
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
- }
- makeSaveInfo()?.let { setSaveInfo(it) }
- setClientState(clientState)
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
- }
+ companion object {
- /**
- * 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()
- }
+ 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
index e460dd35..46f7d821 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt
@@ -26,88 +26,74 @@ 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)
- }
+ 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
+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
+ 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
+ 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)
+ 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
- )
+ 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 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(
+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 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 makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
-fun makeWarningMetadata(context: Context) = DatasetMetadata(
+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
-)
+fun makeHeaderMetadata(title: String) = DatasetMetadata(title, null, 0)
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt b/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt
index cb4bea07..cc759e9a 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/crypto/GpgIdentifier.kt
@@ -8,36 +8,33 @@ package dev.msfjarvis.aps.util.crypto
import me.msfjarvis.openpgpktx.util.OpenPgpUtils
sealed class GpgIdentifier {
- data class KeyId(val id: Long) : GpgIdentifier()
- data class UserId(val email: String) : GpgIdentifier()
+ data class KeyId(val id: Long) : GpgIdentifier()
+ data class UserId(val email: String) : GpgIdentifier()
- companion object {
- @OptIn(ExperimentalUnsignedTypes::class)
- fun fromString(identifier: String): GpgIdentifier? {
- if (identifier.isEmpty()) return null
- // Match long key IDs:
- // FF22334455667788 or 0xFF22334455667788
- val maybeLongKeyId = identifier.removePrefix("0x").takeIf {
- it.matches("[a-fA-F0-9]{16}".toRegex())
- }
- if (maybeLongKeyId != null) {
- val keyId = maybeLongKeyId.toULong(16)
- return KeyId(keyId.toLong())
- }
+ companion object {
+ @OptIn(ExperimentalUnsignedTypes::class)
+ fun fromString(identifier: String): GpgIdentifier? {
+ if (identifier.isEmpty()) return null
+ // Match long key IDs:
+ // FF22334455667788 or 0xFF22334455667788
+ val maybeLongKeyId = identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{16}".toRegex()) }
+ if (maybeLongKeyId != null) {
+ val keyId = maybeLongKeyId.toULong(16)
+ return KeyId(keyId.toLong())
+ }
- // Match fingerprints:
- // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
- val maybeFingerprint = identifier.removePrefix("0x").takeIf {
- it.matches("[a-fA-F0-9]{40}".toRegex())
- }
- if (maybeFingerprint != null) {
- // Truncating to the long key ID is not a security issue since OpenKeychain only accepts
- // non-ambiguous key IDs.
- val keyId = maybeFingerprint.takeLast(16).toULong(16)
- return KeyId(keyId.toLong())
- }
+ // Match fingerprints:
+ // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
+ val maybeFingerprint = identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{40}".toRegex()) }
+ if (maybeFingerprint != null) {
+ // Truncating to the long key ID is not a security issue since OpenKeychain only
+ // accepts
+ // non-ambiguous key IDs.
+ val keyId = maybeFingerprint.takeLast(16).toULong(16)
+ return KeyId(keyId.toLong())
+ }
- return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) }
- }
+ return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) }
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt b/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt
index e200320e..94103118 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/AndroidExtensions.kt
@@ -34,146 +34,115 @@ 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
+ * 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 <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) {
- setOnShowListener {
- findViewById<T>(id)?.apply {
- setOnFocusChangeListener { v, _ ->
- v.post {
- context.getSystemService<InputMethodManager>()
- ?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT)
- }
- }
- requestFocus()
- }
+ setOnShowListener {
+ findViewById<T>(id)?.apply {
+ setOnFocusChangeListener { v, _ ->
+ v.post { context.getSystemService<InputMethodManager>()?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) }
+ }
+ requestFocus()
}
+ }
}
-/**
- * Get an instance of [AutofillManager]. Only
- * available on Android Oreo and above
- */
+/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */
val Context.autofillManager: AutofillManager?
- @RequiresApi(Build.VERSION_CODES.O)
- get() = getSystemService()
+ @RequiresApi(Build.VERSION_CODES.O) get() = getSystemService()
-/**
- * Get an instance of [ClipboardManager]
- */
+/** Get an instance of [ClipboardManager] */
val Context.clipboard
- get() = getSystemService<ClipboardManager>()
+ get() = getSystemService<ClipboardManager>()
-/**
- * Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at
- * each call site
- */
+/** 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.
- */
+/** 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]
- */
+/** 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
- )
+ 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]
- */
+/** Get an instance of [KeyguardManager] */
val Context.keyguardManager: KeyguardManager
- get() = getSystemService()!!
+ get() = getSystemService()!!
-/**
- * Get the default [SharedPreferences] instance
- */
+/** Get the default [SharedPreferences] instance */
val Context.sharedPrefs: SharedPreferences
- get() = PreferenceManager.getDefaultSharedPreferences(applicationContext)
-
+ get() = PreferenceManager.getDefaultSharedPreferences(applicationContext)
-/**
- * Resolve [attr] from the [Context]'s theme
- */
+/** 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
+ 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]
+ * Commit changes to the store from a [FragmentActivity] using a custom implementation of
+ * [GitOperation]
*/
suspend fun FragmentActivity.commitChange(
- message: String,
+ message: String,
): Result<Unit, Throwable> {
- if (!PasswordRepository.isGitRepo()) {
- return Ok(Unit)
- }
- return object : GitOperation(this@commitChange) {
- override val commands = arrayOf(
- // Stage all files
- git.add().addFilepattern("."),
- // Populate the changed files count
- git.status(),
- // Commit everything! If anything changed, that is.
- git.commit().setAll(true).setMessage(message),
+ 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()
+ override fun preExecute(): Boolean {
+ d { "Committing with message: '$message'" }
+ return true
+ }
+ }
+ .execute()
}
-/**
- * Check if [permission] has been granted to the app.
- */
+/** Check if [permission] has been granted to the app. */
fun FragmentActivity.isPermissionGranted(permission: String): Boolean {
- return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
+ 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]
+ * 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,
+ 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
+ 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
- */
+/** 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
- */
+/** Convert this [String] to its [Base64] representation */
fun String.base64(): String {
- return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP)
+ 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
index 9b6d044c..a6d0066c 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/Extensions.kt
@@ -12,53 +12,40 @@ import java.util.Date
import org.eclipse.jgit.lib.ObjectId
import org.eclipse.jgit.revwalk.RevCommit
-/**
- * The default OpenPGP provider for the app
- */
+/** The default OpenPGP provider for the app */
const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
-/**
- * Clears the given [flag] from the value of this [Int]
- */
+/** Clears the given [flag] from the value of this [Int] */
fun Int.clearFlag(flag: Int): Int {
- return this and flag.inv()
+ return this and flag.inv()
}
-/**
- * Checks if this [Int] contains the given [flag]
- */
+/** Checks if this [Int] contains the given [flag] */
infix fun Int.hasFlag(flag: Int): Boolean {
- return this and flag == flag
+ return this and flag == flag
}
-/**
- * Checks whether this [File] is a directory that contains [other].
- */
+/** 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
+ 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
+ // 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]
+ * 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)
+ return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath)
}
-/**
- * Recursively lists the files in this [File], skipping any directories it encounters.
- */
+/** Recursively lists the files in this [File], skipping any directories it encounters. */
fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList()
/**
@@ -67,7 +54,7 @@ fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toLis
* @see RevCommit.getId
*/
val RevCommit.hash: String
- get() = ObjectId.toString(id)
+ get() = ObjectId.toString(id)
/**
* Time this commit was made with second precision.
@@ -75,16 +62,16 @@ val RevCommit.hash: String
* @see RevCommit.commitTime
*/
val RevCommit.time: Date
- get() {
- val epochSeconds = commitTime.toLong()
- val epochMilliseconds = epochSeconds * 1000
- return Date(epochMilliseconds)
- }
+ 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.
+ * Splits this [String] into an [Array] of [String] s, split on the UNIX LF line ending and stripped
+ * of any empty lines.
*/
fun String.splitLines(): Array<String> {
- return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
+ 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
index 088b15f6..3a256ee7 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentExtensions.kt
@@ -11,31 +11,31 @@ 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.
- */
+/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */
fun Fragment.isPermissionGranted(permission: String): Boolean {
- return requireActivity().isPermissionGranted(permission)
+ return requireActivity().isPermissionGranted(permission)
}
-/**
- * Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity]
- */
+/** 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
+ * 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)
- }
+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
index af2afe9e..5a1b554f 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/extensions/FragmentViewBindingDelegate.kt
@@ -5,7 +5,6 @@
package dev.msfjarvis.aps.util.extensions
-
import android.view.LayoutInflater
import android.view.View
import androidx.appcompat.app.AppCompatActivity
@@ -18,48 +17,49 @@ 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
+ * Imported from
+ * https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
*/
-class FragmentViewBindingDelegate<T : ViewBinding>(
- val fragment: Fragment,
- val viewBindingFactory: (View) -> T
-) : ReadOnlyProperty<Fragment, T> {
+class FragmentViewBindingDelegate<T : ViewBinding>(val fragment: Fragment, val viewBindingFactory: (View) -> T) :
+ ReadOnlyProperty<Fragment, T> {
- private var binding: T? = null
+ 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
- }
- })
+ 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.")
- }
+ override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
+ val binding = binding
+ if (binding != null) {
+ return binding
+ }
- return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
+ val lifecycle = fragment.viewLifecycleOwner.lifecycle
+ if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
+ throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
}
+
+ return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
+ }
}
fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
- FragmentViewBindingDelegate(this, viewBindingFactory)
+ FragmentViewBindingDelegate(this, viewBindingFactory)
inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) =
- lazy(LazyThreadSafetyMode.NONE) {
- bindingInflater.invoke(layoutInflater)
- }
+ 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
index 44fd6d3f..04809fdd 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/ErrorMessages.kt
@@ -12,57 +12,54 @@ import dev.msfjarvis.aps.R
import java.net.UnknownHostException
/**
- * Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute].
+ * 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!!
+ override val message = super.message!!
- companion object {
+ companion object {
- private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt)
- }
+ 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) {
+ /** Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. */
+ sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
- object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error)
- object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error)
- }
+ object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error)
+ object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error)
+ }
- /**
- * Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand].
- */
- sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
+ /** 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 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)
- }
+ 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
+ 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
index 87f1a8fd..4546aee1 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitCommandExecutor.kt
@@ -26,96 +26,87 @@ import org.eclipse.jgit.lib.PersonIdent
import org.eclipse.jgit.transport.RemoteRefUpdate
class GitCommandExecutor(
- private val activity: FragmentActivity,
- private val operation: GitOperation,
+ private val activity: FragmentActivity,
+ private val operation: GitOperation,
) {
- suspend fun execute(): Result<Unit, Throwable> {
- val snackbar = activity.snackbar(
- message = activity.resources.getString(R.string.git_operation_running),
- length = Snackbar.LENGTH_INDEFINITE,
- )
- // Count the number of uncommitted files
- var nbChanges = 0
- return runCatching {
- for (command in operation.commands) {
- when (command) {
- is StatusCommand -> {
- val res = withContext(Dispatchers.IO) {
- command.call()
- }
- nbChanges = res.uncommittedChanges.size
- }
- is CommitCommand -> {
- // the previous status will eventually be used to avoid a commit
- if (nbChanges > 0) {
- withContext(Dispatchers.IO) {
- val name = GitSettings.authorName.ifEmpty { "root" }
- val email = GitSettings.authorEmail.ifEmpty { "localhost" }
- val identity = PersonIdent(name, email)
- command.setAuthor(identity).setCommitter(identity).call()
- }
- }
- }
- is PullCommand -> {
- val result = withContext(Dispatchers.IO) {
- command.call()
- }
- if (result.rebaseResult != null) {
- if (!result.rebaseResult.status.isSuccessful) {
- throw PullException.PullRebaseFailed
- }
- } else if (result.mergeResult != null) {
- if (!result.mergeResult.mergeStatus.isSuccessful) {
- throw PullException.PullMergeFailed
- }
- }
- }
- is PushCommand -> {
- val results = withContext(Dispatchers.IO) {
- command.call()
- }
- for (result in results) {
- // Code imported (modified) from Gerrit PushOp, license Apache v2
- for (rru in result.remoteUpdates) {
- when (rru.status) {
- RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward
- RemoteRefUpdate.Status.REJECTED_NODELETE,
- RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
- RemoteRefUpdate.Status.NON_EXISTING,
- RemoteRefUpdate.Status.NOT_ATTEMPTED,
- -> throw PushException.Generic(rru.status.name)
- RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
- throw if ("non-fast-forward" == rru.message) {
- PushException.RemoteRejected
- } else {
- PushException.Generic(rru.message)
- }
- }
- RemoteRefUpdate.Status.UP_TO_DATE -> {
- withContext(Dispatchers.Main) {
- Toast.makeText(
- activity.applicationContext,
- activity.applicationContext.getString(R.string.git_push_up_to_date),
- Toast.LENGTH_SHORT
- ).show()
- }
- }
- else -> {
- }
- }
- }
- }
+ suspend fun execute(): Result<Unit, Throwable> {
+ val snackbar =
+ activity.snackbar(
+ message = activity.resources.getString(R.string.git_operation_running),
+ length = Snackbar.LENGTH_INDEFINITE,
+ )
+ // Count the number of uncommitted files
+ var nbChanges = 0
+ return runCatching {
+ for (command in operation.commands) {
+ when (command) {
+ is StatusCommand -> {
+ val res = withContext(Dispatchers.IO) { command.call() }
+ nbChanges = res.uncommittedChanges.size
+ }
+ is CommitCommand -> {
+ // the previous status will eventually be used to avoid a commit
+ if (nbChanges > 0) {
+ withContext(Dispatchers.IO) {
+ val name = GitSettings.authorName.ifEmpty { "root" }
+ val email = GitSettings.authorEmail.ifEmpty { "localhost" }
+ val identity = PersonIdent(name, email)
+ command.setAuthor(identity).setCommitter(identity).call()
+ }
+ }
+ }
+ is PullCommand -> {
+ val result = withContext(Dispatchers.IO) { command.call() }
+ if (result.rebaseResult != null) {
+ if (!result.rebaseResult.status.isSuccessful) {
+ throw PullException.PullRebaseFailed
+ }
+ } else if (result.mergeResult != null) {
+ if (!result.mergeResult.mergeStatus.isSuccessful) {
+ throw PullException.PullMergeFailed
+ }
+ }
+ }
+ is PushCommand -> {
+ val results = withContext(Dispatchers.IO) { command.call() }
+ for (result in results) {
+ // Code imported (modified) from Gerrit PushOp, license Apache v2
+ for (rru in result.remoteUpdates) {
+ when (rru.status) {
+ RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward
+ RemoteRefUpdate.Status.REJECTED_NODELETE,
+ RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
+ RemoteRefUpdate.Status.NON_EXISTING,
+ RemoteRefUpdate.Status.NOT_ATTEMPTED, -> throw PushException.Generic(rru.status.name)
+ RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
+ throw if ("non-fast-forward" == rru.message) {
+ PushException.RemoteRejected
+ } else {
+ PushException.Generic(rru.message)
}
- else -> {
- withContext(Dispatchers.IO) {
- command.call()
- }
+ }
+ 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 -> {}
}
+ }
}
- }.also {
- snackbar.dismiss()
+ }
+ else -> {
+ withContext(Dispatchers.IO) { command.call() }
+ }
}
+ }
}
+ .also { snackbar.dismiss() }
+ }
}
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
index f8f2af1c..1ba65086 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/git/GitLogModel.kt
@@ -15,41 +15,37 @@ import org.eclipse.jgit.api.Git
import org.eclipse.jgit.revwalk.RevCommit
private fun commits(): Iterable<RevCommit> {
- 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()
- }
+ 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.
+ * Provides [GitCommit] s from a git-log of the password git repository.
*
* All commits are acquired on the first request to this object.
*/
class GitLogModel {
- // All commits are acquired here at once. Acquiring the commits in batches would not have been
- // entirely sensible because the amount of computation required to obtain commit number n from
- // the log includes the amount of computation required to obtain commit number n-1 from the log.
- // This is because the commit graph is walked from HEAD to the last commit to obtain.
- // Additionally, tests with 1000 commits in the log have not produced a significant delay in the
- // user experience.
- private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) {
- commits().map {
- GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time)
- }.toMutableList()
- }
- val size = cache.size
+ // All commits are acquired here at once. Acquiring the commits in batches would not have been
+ // entirely sensible because the amount of computation required to obtain commit number n from
+ // the log includes the amount of computation required to obtain commit number n-1 from the log.
+ // This is because the commit graph is walked from HEAD to the last commit to obtain.
+ // Additionally, tests with 1000 commits in the log have not produced a significant delay in the
+ // user experience.
+ private val cache: MutableList<GitCommit> by 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)
- }
+ 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
index 4b3f0154..196d6d48 100644
--- 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
@@ -13,44 +13,45 @@ import org.eclipse.jgit.lib.RepositoryState
class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
- private val merging = repository.repositoryState == RepositoryState.MERGING
- private val resetCommands = arrayOf(
- // git checkout -b conflict-branch
- git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"),
- // push the changes
- git.push().setRemote("origin"),
- // switch back to ${gitBranch}
- git.checkout().setName(remoteBranch),
+ private val merging = repository.repositoryState == RepositoryState.MERGING
+ private val resetCommands =
+ arrayOf(
+ // git checkout -b conflict-branch
+ git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"),
+ // push the changes
+ git.push().setRemote("origin"),
+ // switch back to ${gitBranch}
+ git.checkout().setName(remoteBranch),
)
- override val commands by lazy(LazyThreadSafetyMode.NONE) {
- if (merging) {
- // We need to run some non-command operations first
- repository.writeMergeCommitMsg(null)
- repository.writeMergeHeads(null)
- arrayOf(
- // reset hard back to our local HEAD
- git.reset().setMode(ResetCommand.ResetType.HARD),
- *resetCommands,
- )
- } else {
- arrayOf(
- // abort the rebase
- git.rebase().setOperation(RebaseCommand.Operation.ABORT),
- *resetCommands,
- )
- }
+ override val commands by lazy(LazyThreadSafetyMode.NONE) {
+ if (merging) {
+ // We need to run some non-command operations first
+ repository.writeMergeCommitMsg(null)
+ repository.writeMergeHeads(null)
+ arrayOf(
+ // reset hard back to our local HEAD
+ git.reset().setMode(ResetCommand.ResetType.HARD),
+ *resetCommands,
+ )
+ } else {
+ arrayOf(
+ // abort the rebase
+ git.rebase().setOperation(RebaseCommand.Operation.ABORT),
+ *resetCommands,
+ )
}
+ }
- override fun preExecute() = if (!git.repository.repositoryState.isRebasing && !merging) {
- MaterialAlertDialogBuilder(callingActivity)
- .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
- .setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded))
- .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ ->
- callingActivity.finish()
- }.show()
- false
+ override fun preExecute() =
+ if (!git.repository.repositoryState.isRebasing && !merging) {
+ MaterialAlertDialogBuilder(callingActivity)
+ .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
+ .setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded))
+ .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() }
+ .show()
+ false
} else {
- true
+ 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
index 3efc9088..a5a4f5d0 100644
--- 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
@@ -16,7 +16,8 @@ import org.eclipse.jgit.api.GitCommand
*/
class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) {
- override val commands: Array<GitCommand<out Any>> = arrayOf(
- Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri),
+ override val commands: Array<GitCommand<out Any>> =
+ arrayOf(
+ Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri),
)
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/CredentialFinder.kt
index 0833d935..40869cf2 100644
--- 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
@@ -24,80 +24,71 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
-class CredentialFinder(
- val callingActivity: FragmentActivity,
- val authMode: AuthMode
-) : InteractivePasswordFinder() {
+class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) : InteractivePasswordFinder() {
- override fun askForPassword(cont: Continuation<String?>, 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)
+ override fun askForPassword(cont: Continuation<String?>, 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<TextInputLayout>(R.id.git_auth_passphrase_layout)
- val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
- editCredential.setHint(hintRes)
- val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
- rememberCredential.setText(rememberRes)
- if (isRetry) {
- credentialLayout.error = callingActivity.resources.getString(errorRes)
- // Reset error when user starts entering a password
- editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
- }
- MaterialAlertDialogBuilder(callingActivity).run {
- setTitle(R.string.passphrase_dialog_title)
- setMessage(messageRes)
- setView(dialogView)
- setPositiveButton(R.string.dialog_ok) { _, _ ->
- val credential = editCredential.text.toString()
- if (rememberCredential.isChecked) {
- gitOperationPrefs.edit {
- putString(credentialPref, credential)
- }
- }
- cont.resume(credential)
- }
- setNegativeButton(R.string.dialog_cancel) { _, _ ->
- cont.resume(null)
- }
- setOnCancelListener {
- cont.resume(null)
- }
- create()
- }.run {
- requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential)
- show()
+ @SuppressLint("InflateParams") val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
+ val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
+ val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
+ editCredential.setHint(hintRes)
+ val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
+ rememberCredential.setText(rememberRes)
+ if (isRetry) {
+ credentialLayout.error = callingActivity.resources.getString(errorRes)
+ // Reset error when user starts entering a password
+ editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
+ }
+ MaterialAlertDialogBuilder(callingActivity)
+ .run {
+ setTitle(R.string.passphrase_dialog_title)
+ setMessage(messageRes)
+ setView(dialogView)
+ setPositiveButton(R.string.dialog_ok) { _, _ ->
+ val credential = editCredential.text.toString()
+ if (rememberCredential.isChecked) {
+ gitOperationPrefs.edit { putString(credentialPref, credential) }
}
- } else {
- cont.resume(storedCredential)
+ cont.resume(credential)
+ }
+ setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) }
+ setOnCancelListener { cont.resume(null) }
+ create()
+ }
+ .run {
+ requestInputFocusOnView<TextInputEditText>(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
index 128ca578..0d0dac9c 100644
--- 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
@@ -50,170 +50,167 @@ import org.eclipse.jgit.transport.URIish
*/
abstract class GitOperation(protected val callingActivity: FragmentActivity) {
- abstract val commands: Array<GitCommand<out Any>>
- 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
+ abstract val commands: Array<GitCommand<out Any>>
+ 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
}
- private fun getSshKey(make: Boolean) {
- runCatching {
- val intent = if (make) {
- Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java)
- } else {
- Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java)
- }
- callingActivity.startActivity(intent)
- }.onFailure { e ->
- e(e)
- }
- }
+ override fun supports(vararg items: CredentialItem) =
+ items.all { it is CredentialItem.Username || it is CredentialItem.Password }
- private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) {
- sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
- commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
- command.setTransportConfigCallback { transport: Transport ->
- (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory
- credentialsProvider?.let { transport.credentialsProvider = it }
- }
- command.setTimeout(CONNECT_TIMEOUT)
- }
+ override fun reset(uri: URIish?) {
+ cachedPassword?.fill(0.toChar())
+ cachedPassword = null
}
-
- /**
- * Executes the GitCommand in an async task.
- */
- suspend fun execute(): Result<Unit, Throwable> {
- if (!preExecute()) {
- return Ok(Unit)
+ }
+
+ private fun getSshKey(make: Boolean) {
+ runCatching {
+ val intent =
+ if (make) {
+ Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java)
+ } else {
+ Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java)
}
- val operationResult = GitCommandExecutor(
- callingActivity,
- this,
- ).execute()
- postExecute()
- return operationResult
+ callingActivity.startActivity(intent)
}
-
- 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()
+ .onFailure { e -> e(e) }
+ }
+
+ private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) {
+ sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
+ commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
+ command.setTransportConfigCallback { transport: Transport ->
+ (transport as? SshTransport)?.sshSessionFactory = sshSessionFactory
+ credentialsProvider?.let { transport.credentialsProvider = it }
+ }
+ command.setTimeout(CONNECT_TIMEOUT)
}
+ }
- suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
- when (authMode) {
- AuthMode.SshKey -> if (SshKey.exists) {
- if (SshKey.mustAuthenticate) {
- val result = withContext(Dispatchers.Main) {
- suspendCoroutine<BiometricAuthenticator.Result> { cont ->
- BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
- 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))
+ /** Executes the GitCommand in an async task. */
+ suspend fun execute(): Result<Unit, Throwable> {
+ if (!preExecute()) {
+ return Ok(Unit)
+ }
+ val operationResult =
+ GitCommandExecutor(
+ callingActivity,
+ this,
+ )
+ .execute()
+ postExecute()
+ return operationResult
+ }
+
+ private fun onMissingSshKeyFile() {
+ MaterialAlertDialogBuilder(callingActivity)
+ .setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
+ .setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
+ .setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ ->
+ getSshKey(false)
+ }
+ .setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ ->
+ getSshKey(true)
+ }
+ .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
+ // Finish the blank GitActivity so user doesn't have to press back
+ callingActivity.finish()
+ }
+ .show()
+ }
+
+ suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
+ when (authMode) {
+ AuthMode.SshKey ->
+ if (SshKey.exists) {
+ if (SshKey.mustAuthenticate) {
+ val result =
+ withContext(Dispatchers.Main) {
+ suspendCoroutine<BiometricAuthenticator.Result> { cont ->
+ BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
+ if (it !is BiometricAuthenticator.Result.Failure) cont.resume(it)
+ }
}
- } else {
- onMissingSshKeyFile()
- // This would correctly cancel the operation but won't surface a user-visible
- // error, allowing users to make the SSH key selection.
+ }
+ 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()
+ }
}
- AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity))
- AuthMode.Password -> {
- val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password))
- registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider)
- }
- AuthMode.None -> {
- }
+ } 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))
}
- return execute()
+ 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
+ /** Called before execution of the Git operation. Return false to cancel. */
+ open fun preExecute() = true
- private suspend fun postExecute() {
- withContext(Dispatchers.IO) {
- sshSessionFactory?.close()
- }
- }
+ private suspend fun postExecute() {
+ withContext(Dispatchers.IO) { sshSessionFactory?.close() }
+ }
- companion object {
+ companion object {
- /**
- * Timeout in seconds before [TransportCommand] will abort a stalled IO operation.
- */
- private const val CONNECT_TIMEOUT = 10
- }
+ /** 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
index e517aac0..394b7cb4 100644
--- 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
@@ -8,27 +8,28 @@ import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
import org.eclipse.jgit.api.GitCommand
class PullOperation(
- callingActivity: ContinuationContainerActivity,
- rebase: Boolean,
+ callingActivity: ContinuationContainerActivity,
+ rebase: Boolean,
) : GitOperation(callingActivity) {
- /**
- * The story of why the pull operation is committing files goes like this: Once upon a time when
- * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made
- * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing.
- * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation]
- * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation,
- * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
- * replicating [SyncOperation] but leaving the pushing part to [PushOperation].
- */
- override val commands: Array<GitCommand<out Any>> = arrayOf(
- // Stage all files
- git.add().addFilepattern("."),
- // Populate the changed files count
- git.status(),
- // Commit everything! If needed, obviously.
- git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
- // Pull and rebase on top of the remote branch
- git.pull().setRebase(rebase).setRemote("origin"),
+ /**
+ * The story of why the pull operation is committing files goes like this: Once upon a time when
+ * the world was burning and Blade Runner 2049 was real life (in the worst way), we were made
+ * aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing.
+ * So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation]
+ * and then a [PushOperation]. To make the behavior identical despite this suboptimal situation,
+ * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
+ * replicating [SyncOperation] but leaving the pushing part to [PushOperation].
+ */
+ override val commands: Array<GitCommand<out Any>> =
+ arrayOf(
+ // Stage all files
+ git.add().addFilepattern("."),
+ // Populate the changed files count
+ git.status(),
+ // Commit everything! If needed, obviously.
+ git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
+ // Pull and rebase on top of the remote branch
+ git.pull().setRebase(rebase).setRemote("origin"),
)
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/PushOperation.kt
index de07087e..a9f168ad 100644
--- 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
@@ -9,7 +9,8 @@ import org.eclipse.jgit.api.GitCommand
class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
- override val commands: Array<GitCommand<out Any>> = arrayOf(
- git.push().setPushAll().setRemote("origin"),
+ override val commands: Array<GitCommand<out Any>> =
+ arrayOf(
+ git.push().setPushAll().setRemote("origin"),
)
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/operation/ResetToRemoteOperation.kt
index 80848602..dccd69b0 100644
--- 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
@@ -9,15 +9,18 @@ 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),
+ 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
index fc63ce0c..589c6305 100644
--- 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
@@ -7,20 +7,21 @@ package dev.msfjarvis.aps.util.git.operation
import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
class SyncOperation(
- callingActivity: ContinuationContainerActivity,
- rebase: Boolean,
+ callingActivity: ContinuationContainerActivity,
+ rebase: Boolean,
) : GitOperation(callingActivity) {
- override val commands = arrayOf(
- // Stage all files
- git.add().addFilepattern("."),
- // Populate the changed files count
- git.status(),
- // Commit everything! If needed, obviously.
- git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
- // Pull and rebase on top of the remote branch
- git.pull().setRebase(rebase).setRemote("origin"),
- // Push it all back
- git.push().setPushAll().setRemote("origin"),
+ override val commands =
+ arrayOf(
+ // Stage all files
+ git.add().addFilepattern("."),
+ // Populate the changed files count
+ git.status(),
+ // Commit everything! If needed, obviously.
+ git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
+ // Pull and rebase on top of the remote branch
+ git.pull().setRebase(rebase).setRemote("origin"),
+ // Push it all back
+ git.push().setPushAll().setRemote("origin"),
)
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/ContinuationContainerActivity.kt
index 4085c0c5..523ff5b6 100644
--- 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
@@ -14,24 +14,21 @@ import kotlin.coroutines.resumeWithException
import net.schmizz.sshj.common.DisconnectReason
import net.schmizz.sshj.userauth.UserAuthException
-/**
- * Workaround for https://msfjarvis.dev/aps/issue/1164
- */
+/** Workaround for https://msfjarvis.dev/aps/issue/1164 */
open class ContinuationContainerActivity : AppCompatActivity {
- constructor() : super()
- constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
+ constructor() : super()
+ constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
- var stashedCont: Continuation<Intent>? = null
+ var stashedCont: Continuation<Intent>? = null
- val continueAfterUserInteraction = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
- stashedCont?.let { cont ->
- stashedCont = null
- val data = result.data
- if (data != null)
- cont.resume(data)
- else
- cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER))
- }
+ 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
index 23f780c3..acb7d8d7 100644
--- 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
@@ -38,162 +38,170 @@ 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 {
+class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) :
+ KeyProvider, Closeable {
- companion object {
+ companion object {
- suspend fun prepareAndUse(activity: ContinuationContainerActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) {
- withContext(Dispatchers.Main) {
- OpenKeychainKeyProvider(activity)
- }.prepareAndUse(block)
- }
+ 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 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 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))
}
- }
- 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)
+ override fun onError() {
+ throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
}
- 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
- }
+ if (keyId == null) {
+ selectKey()
}
-
- 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" }
+ 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 parseResult(request: Request, result: Intent): ApiResponse {
- return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) {
- SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
- ApiResponse.Success(when (request) {
- is KeySelectionRequest -> KeySelectionResponse(result)
- is SshPublicKeyRequest -> SshPublicKeyResponse(result)
- is SigningRequest -> SigningResponse(result)
- else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
- })
- }
- SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
- val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
- val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) {
- suspendCoroutine { cont ->
- activity.stashedCont = cont
- activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build())
- }
- }
- executeApiRequest(request, resultOfUserInteraction)
- }
- else -> {
- val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
- val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}")
- when (error?.error) {
- SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception)
- else -> ApiResponse.GeneralError(exception)
- }
- }
- }
- }
-
- private fun makePrivateKey() {
- check(keyId != null && publicKey != null)
- privateKey = object : OpenKeychainPrivateKey {
- override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
- when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) {
- is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
- is ApiResponse.GeneralError -> throw signingResponse.exception
- is ApiResponse.NoSuchKey -> throw signingResponse.exception
- }
-
- override fun getAlgorithm() = publicKey!!.algorithm
- override fun getParams() = (publicKey as? ECKey)?.params
- }
+ 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
}
-
- override fun close() {
- activity.lifecycleScope.launch {
- withContext(Dispatchers.Main) {
- activity.continueAfterUserInteraction.unregister()
+ }
+
+ 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<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
+ val exception =
+ UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}")
+ when (error?.error) {
+ SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY ->
+ ApiResponse.NoSuchKey(exception)
+ else -> ApiResponse.GeneralError(exception)
}
- sshServiceConnection.disconnect()
+ }
+ }
+ }
+
+ private fun makePrivateKey() {
+ check(keyId != null && publicKey != null)
+ privateKey =
+ object : OpenKeychainPrivateKey {
+ override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
+ when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) {
+ is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
+ is ApiResponse.GeneralError -> throw signingResponse.exception
+ is ApiResponse.NoSuchKey -> throw signingResponse.exception
+ }
+
+ override fun getAlgorithm() = publicKey!!.algorithm
+ override fun getParams() = (publicKey as? ECKey)?.params
+ }
+ }
+
+ override fun close() {
+ activity.lifecycleScope.launch {
+ withContext(Dispatchers.Main) { activity.continueAfterUserInteraction.unregister() }
}
+ sshServiceConnection.disconnect()
+ }
- override fun getPrivate() = privateKey
+ override fun getPrivate() = privateKey
- override fun getPublic() = publicKey
+ override fun getPublic() = publicKey
- override fun getType(): KeyType = KeyType.fromKey(publicKey)
+ override fun getType(): KeyType = KeyType.fromKey(publicKey)
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/OpenKeychainWrappedKeyAlgorithmFactory.kt
index e64f9909..0ab01ba5 100644
--- 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
@@ -8,8 +8,6 @@ import com.hierynomus.sshj.key.KeyAlgorithm
import java.io.ByteArrayOutputStream
import java.security.PrivateKey
import java.security.interfaces.ECKey
-import java.security.interfaces.ECPrivateKey
-import java.security.spec.ECParameterSpec
import kotlinx.coroutines.runBlocking
import net.schmizz.sshj.common.Buffer
import net.schmizz.sshj.common.Factory
@@ -18,79 +16,83 @@ import org.openintents.ssh.authentication.SshAuthenticationApi
interface OpenKeychainPrivateKey : PrivateKey, ECKey {
- suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray
+ suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray
- override fun getFormat() = null
- override fun getEncoded() = null
+ override fun getFormat() = null
+ override fun getEncoded() = null
}
-class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) : Factory.Named<KeyAlgorithm> by factory {
+class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) :
+ Factory.Named<KeyAlgorithm> by factory {
- override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create())
+ 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
+ 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)
+ override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
}
-class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : Signature by wrappedSignature {
+class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) :
+ Signature by wrappedSignature {
- private val data = ByteArrayOutputStream()
+ private val data = ByteArrayOutputStream()
- private var bridgedPrivateKey: OpenKeychainPrivateKey? = null
+ private var bridgedPrivateKey: OpenKeychainPrivateKey? = null
- override fun initSign(prvkey: PrivateKey?) {
- if (prvkey is OpenKeychainPrivateKey) {
- bridgedPrivateKey = prvkey
- } else {
- wrappedSignature.initSign(prvkey)
- }
+ 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?) {
+ 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 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)
- }
+ override fun sign(): ByteArray? =
+ if (bridgedPrivateKey != null) {
+ runBlocking { bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) }
} else {
- wrappedSignature.sign()
+ 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()
- }
+ 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)
+ 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
index 793bbd28..37414707 100644
--- 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
@@ -51,286 +51,288 @@ 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) }
+ KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
}
private val KeyStore.sshPrivateKey
- get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
+ get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
private val KeyStore.sshPublicKey
- get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
+ 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()
+ 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)}"
+ 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
- }
+ 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")
}
-
- 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)
+ }
+ .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 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"),
- ;
+ private val context: Context
+ get() = Application.instance.applicationContext
- companion object {
+ private val privateKeyFile
+ get() = File(context.filesDir, ".ssh_key")
+ private val publicKeyFile
+ get() = File(context.filesDir, ".ssh_key.pub")
- 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 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 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
- }
+ 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
+ }
- 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))
+ private enum class Type(val value: String) {
+ Imported("imported"),
+ KeystoreNative("keystore_native"),
+ KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
- // 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"))
+ // Behaves like `Imported`, but allows to view the public key.
+ LegacyGenerated("legacy_generated"),
+ ;
- type = Type.Imported
- }
+ companion object {
- @Deprecated("To be used only in Migrations.kt")
- fun useLegacyKey(isGenerated: Boolean) {
- type = if (isGenerated) Type.LegacyGenerated else Type.Imported
+ fun fromValue(value: String?): Type? = values().associateBy { it.value }[value]
}
-
- @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()
+ }
+
+ 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()
}
-
- @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()
- }
+ if (publicKeyFile.isFile) {
+ publicKeyFile.delete()
}
-
- @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
+ 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()
+ }
}
- 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()
+ @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()
}
-
- // 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 {
+ @Suppress("BlockingMethodInNonBlockingContext")
+ suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) =
+ withContext(Dispatchers.IO) {
+ delete()
- override fun getPublic(): PublicKey = runCatching {
- androidKeystore.sshPublicKey!!
- }.getOrElse { error ->
- e(error)
- throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error)
- }
+ 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) }
- override fun getPrivate(): PrivateKey = runCatching {
- androidKeystore.sshPrivateKey!!
- }.getOrElse { error ->
- e(error)
- throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error)
- }
+ // Write public key in SSH format to .ssh_key.pub.
+ publicKeyFile.writeText(toSshPublicKey(keyPair.public))
- override fun getType(): KeyType = KeyType.fromKey(public)
+ type = Type.KeystoreWrappedEd25519
}
- 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)
+ 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
+ }
- 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)
+ 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)
- }
+ 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
index c3339676..e93787f4 100644
--- 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
@@ -33,250 +33,240 @@ 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)
+ // 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)
+ 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)
- }
- }
+ private class TimberLogger(name: String) : AbstractLogger(name) {
- override fun getLogger(name: String): Logger {
- return TimberLogger(name)
- }
-
- override fun getLogger(clazz: Class<*>): Logger {
- return TimberLogger(clazz.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")
-class SshjConfig : ConfigImpl() {
-
- init {
- loggerFactory = TimberLoggerFactory
- keepAliveProvider = KeepAliveProvider.HEARTBEAT
- version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1"
-
- initKeyExchangeFactories()
- initKeyAlgorithms()
- initRandomFactory()
- initFileKeyProviderFactories()
- initCipherFactories()
- initCompressionFactories()
- initMACFactories()
+ override fun t(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).v(t, message.fix(), *args)
}
- private fun initKeyExchangeFactories() {
- keyExchangeFactories = listOf(
- Curve25519SHA256.Factory(),
- FactoryLibSsh(),
- ECDHNistP.Factory521(),
- ECDHNistP.Factory384(),
- ECDHNistP.Factory256(),
- DHGexSHA256.Factory(),
- // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get
- // rsa-sha2-* key types to work with some servers (e.g. GitHub).
- ExtInfoClientFactory(),
- )
+ override fun d(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).d(t, message.fix(), *args)
}
- private fun initKeyAlgorithms() {
- keyAlgorithms = listOf(
- KeyAlgorithms.SSHRSACertV01(),
- KeyAlgorithms.EdDSA25519(),
- KeyAlgorithms.ECDSASHANistp521(),
- KeyAlgorithms.ECDSASHANistp384(),
- KeyAlgorithms.ECDSASHANistp256(),
- KeyAlgorithms.RSASHA512(),
- KeyAlgorithms.RSASHA256(),
- KeyAlgorithms.SSHRSA(),
- ).map {
- OpenKeychainWrappedKeyAlgorithmFactory(it)
- }
+ override fun i(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).i(t, message.fix(), *args)
}
- private fun initRandomFactory() {
- randomFactory = SingletonRandomFactory(JCERandom.Factory())
+ override fun w(message: String, t: Throwable?, vararg args: Any?) {
+ Timber.tag(name).w(t, message.fix(), *args)
}
- private fun initFileKeyProviderFactories() {
- fileKeyProviderFactories = listOf(
- OpenSSHKeyV1KeyFile.Factory(),
- PKCS8KeyFile.Factory(),
- PKCS5KeyFile.Factory(),
- OpenSSHKeyFile.Factory(),
- PuTTYKeyFile.Factory(),
- )
+ 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)
+ }
- private fun initCipherFactories() {
- cipherFactories = listOf(
- GcmCiphers.AES128GCM(),
- GcmCiphers.AES256GCM(),
- BlockCiphers.AES256CTR(),
- BlockCiphers.AES192CTR(),
- BlockCiphers.AES128CTR(),
- )
- }
+ override fun getLogger(clazz: Class<*>): Logger {
+ return TimberLogger(clazz.name)
+ }
+}
- private fun initMACFactories() {
- macFactories = listOf(
- Macs.HMACSHA2512Etm(),
- Macs.HMACSHA2256Etm(),
- Macs.HMACSHA2512(),
- Macs.HMACSHA2256(),
- )
- }
+class SshjConfig : ConfigImpl() {
- private fun initCompressionFactories() {
- compressionFactories = listOf(
- NoneCompression.Factory(),
- )
- }
+ 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(),
+ ECDHNistP.Factory521(),
+ ECDHNistP.Factory384(),
+ ECDHNistP.Factory256(),
+ DHGexSHA256.Factory(),
+ // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to
+ // get
+ // rsa-sha2-* key types to work with some servers (e.g. GitHub).
+ ExtInfoClientFactory(),
+ )
+ }
+
+ private fun initKeyAlgorithms() {
+ keyAlgorithms =
+ listOf(
+ KeyAlgorithms.SSHRSACertV01(),
+ KeyAlgorithms.EdDSA25519(),
+ KeyAlgorithms.ECDSASHANistp521(),
+ KeyAlgorithms.ECDSASHANistp384(),
+ KeyAlgorithms.ECDSASHANistp256(),
+ KeyAlgorithms.RSASHA512(),
+ KeyAlgorithms.RSASHA256(),
+ KeyAlgorithms.SSHRSA(),
+ )
+ .map { OpenKeychainWrappedKeyAlgorithmFactory(it) }
+ }
+
+ private fun initRandomFactory() {
+ randomFactory = SingletonRandomFactory(JCERandom.Factory())
+ }
+
+ private fun initFileKeyProviderFactories() {
+ fileKeyProviderFactories =
+ listOf(
+ OpenSSHKeyV1KeyFile.Factory(),
+ PKCS8KeyFile.Factory(),
+ PKCS5KeyFile.Factory(),
+ OpenSSHKeyFile.Factory(),
+ PuTTYKeyFile.Factory(),
+ )
+ }
+
+ private fun initCipherFactories() {
+ cipherFactories =
+ listOf(
+ GcmCiphers.AES128GCM(),
+ GcmCiphers.AES256GCM(),
+ BlockCiphers.AES256CTR(),
+ BlockCiphers.AES192CTR(),
+ BlockCiphers.AES128CTR(),
+ )
+ }
+
+ private fun initMACFactories() {
+ macFactories =
+ listOf(
+ Macs.HMACSHA2512Etm(),
+ Macs.HMACSHA2256Etm(),
+ Macs.HMACSHA2512(),
+ Macs.HMACSHA2256(),
+ )
+ }
+
+ private fun initCompressionFactories() {
+ compressionFactories =
+ listOf(
+ NoneCompression.Factory(),
+ )
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt b/app/src/main/java/dev/msfjarvis/aps/util/git/sshj/SshjSessionFactory.kt
index 1062fb0e..95676d7f 100644
--- 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
@@ -40,158 +40,155 @@ 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)
+ 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
+ private var isRetry = false
- abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean)
+ abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean)
- final override fun reqPassword(resource: Resource<*>?): CharArray {
- val password = runBlocking(Dispatchers.Main) {
- suspendCoroutine<String?> { cont ->
- askForPassword(cont, isRetry)
- }
- }
- isRetry = true
- return password?.toCharArray()
- ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
- }
+ final override fun reqPassword(resource: Resource<*>?): CharArray {
+ val password = runBlocking(Dispatchers.Main) { suspendCoroutine<String?> { cont -> askForPassword(cont, isRetry) } }
+ isRetry = true
+ return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
+ }
- final override fun shouldRetry(resource: Resource<*>?) = true
+ final override fun shouldRetry(resource: Resource<*>?) = true
}
class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : SshSessionFactory() {
- private var currentSession: SshjSession? = null
+ 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
- }
- }
+ 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()
- }
+ 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)
+ 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}" } }
+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
+ uri
}
- override fun exec(commandName: String?, timeout: Int): Process {
- if (currentCommand != null) {
- w { "Killing old command" }
- disconnect()
+ 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)
+ }
}
- val session = ssh.startSession()
- currentCommand = session
- return SshjProcess(session.exec(commandName), timeout.toLong())
+ }
}
+ return this
+ }
- /**
- * 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()
+ 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 waitFor(): Int {
+ command.join(timeout, TimeUnit.SECONDS)
+ command.close()
+ return exitValue()
+ }
- override fun destroy() = command.close()
+ override fun destroy() = command.close()
- override fun getOutputStream(): OutputStream = command.outputStream
+ override fun getOutputStream(): OutputStream = command.outputStream
- override fun getErrorStream(): InputStream = command.errorStream
+ override fun getErrorStream(): InputStream = command.errorStream
- override fun exitValue(): Int = command.exitStatus
+ override fun exitValue(): Int = command.exitStatus
- override fun getInputStream(): InputStream = command.inputStream
+ 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
index 592b75da..e6eb7e43 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/proxy/ProxyUtils.kt
@@ -15,52 +15,52 @@ import java.net.ProxySelector
import java.net.SocketAddress
import java.net.URI
-/**
- * Utility class for [Proxy] handling.
- */
+/** 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"
+ 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<Proxy> {
- val host = GitSettings.proxyHost
- val port = GitSettings.proxyPort
- return if (host == null || port == -1) {
- mutableListOf(Proxy.NO_PROXY)
- } else {
- mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port)))
- }
- }
+ /** Set the default [Proxy] and [Authenticator] for the app based on user provided settings. */
+ fun setDefaultProxy() {
+ ProxySelector.setDefault(
+ object : ProxySelector() {
+ override fun select(uri: URI?): MutableList<Proxy> {
+ val host = GitSettings.proxyHost
+ val port = GitSettings.proxyPort
+ return if (host == null || port == -1) {
+ mutableListOf(Proxy.NO_PROXY)
+ } else {
+ mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port)))
+ }
+ }
- override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
- if (uri == null || sa == null || ioe == null) {
- throw IllegalArgumentException("Arguments can't be null.")
- }
- }
- })
- val user = GitSettings.proxyUsername ?: ""
- val password = GitSettings.proxyPassword ?: ""
- if (user.isEmpty() || password.isEmpty()) {
- System.clearProperty(HTTP_PROXY_USER_PROPERTY)
- System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
- } else {
- System.setProperty(HTTP_PROXY_USER_PROPERTY, user)
- System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password)
+ override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
+ if (uri == null || sa == null || ioe == null) {
+ throw IllegalArgumentException("Arguments can't be null.")
+ }
}
- Authenticator.setDefault(object : Authenticator() {
- override fun getPasswordAuthentication(): PasswordAuthentication? {
- return if (requestorType == RequestorType.PROXY) {
- PasswordAuthentication(user, password.toCharArray())
- } else {
- 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
index c9361e15..8199ebfc 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/PasswordGenerator.kt
@@ -12,128 +12,118 @@ import dev.msfjarvis.aps.util.extensions.hasFlag
import dev.msfjarvis.aps.util.settings.PreferenceKeys
enum class PasswordOption(val key: String) {
- NoDigits("0"),
- NoUppercaseLetters("A"),
- NoAmbiguousCharacters("B"),
- FullyRandom("s"),
- AtLeastOneSymbol("y"),
- NoLowercaseLetters("L")
+ NoDigits("0"),
+ NoUppercaseLetters("A"),
+ NoAmbiguousCharacters("B"),
+ FullyRandom("s"),
+ AtLeastOneSymbol("y"),
+ NoLowercaseLetters("L")
}
object PasswordGenerator {
- const val DEFAULT_LENGTH = 16
+ 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 = 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"
+ 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<PasswordOption>, targetLength: Int): Boolean {
- ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
- for (possibleOption in PasswordOption.values())
- putBoolean(possibleOption.key, possibleOption in options)
- putInt("length", targetLength)
- }
- return true
- }
-
- 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
+ /**
+ * Enables the [PasswordOption] s in [options] and sets [targetLength] as the length for generated
+ * passwords.
+ */
+ fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean {
+ ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
+ for (possibleOption in PasswordOption.values()) putBoolean(possibleOption.key, possibleOption in options)
+ putInt("length", targetLength)
}
+ return true
+ }
- /**
- * 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
+ 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
+ }
- var phonemes = true
- var pwgenFlags = DIGITS or UPPERS or LOWERS
+ /** 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
- 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 -> {
- }
- }
- }
- }
+ var phonemes = true
+ var pwgenFlags = DIGITS or UPPERS or LOWERS
- 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)
+ 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
+ }
}
- // 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
+ } 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 -> {}
}
+ }
+ }
- 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
+ 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)
+ 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
index 936f2cae..8ef490cc 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomNumberGenerator.kt
@@ -8,26 +8,24 @@ import java.security.SecureRandom
private val secureRandom = SecureRandom()
-/**
- * Returns a number between 0 (inclusive) and [exclusiveBound] (exclusive).
- */
+/** Returns a number between 0 (inclusive) and [exclusiveBound](exclusive). */
fun secureRandomNumber(exclusiveBound: Int) = secureRandom.nextInt(exclusiveBound)
-/**
- * Returns `true` and `false` with probablity 50% each.
- */
+/** Returns `true` and `false` with probablity 50% each. */
fun secureRandomBoolean() = secureRandom.nextBoolean()
/**
- * Returns `true` with probability [percentTrue]% and `false` with probability
- * `(100 - [percentTrue])`%.
+ * 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
+ 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 <T> Array<T>.secureRandomElement() = this[secureRandomNumber(size)]
+
fun <T> List<T>.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
index 33e58228..e92a753b 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPasswordGenerator.kt
@@ -8,38 +8,39 @@ 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("")
+ /**
+ * 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) }
+ 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
index e0f7e387..1b2d0fb7 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgen/RandomPhonemesGenerator.kt
@@ -9,161 +9,161 @@ 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 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 }
- }
+ 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
+ }
- /**
- * 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
+ // 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
}
- return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
+ 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
index 4b398f06..69a4692d 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/CapsType.kt
@@ -5,5 +5,9 @@
package dev.msfjarvis.aps.util.pwgenxkpwd
enum class CapsType {
- lowercase, UPPERCASE, TitleCase, Sentence, As_iS
+ 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
index 41860293..cc8257b4 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/PasswordBuilder.kt
@@ -16,127 +16,120 @@ 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
+ 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()
+ }
- fun setMinimumWordLength(min: Int) = apply {
- minWordLength = min
+ private fun generateRandomSymbolSequence(numSymbols: Int): String {
+ val numbers = StringBuilder(numSymbols)
+ for (i in 0 until numSymbols) {
+ numbers.append(SYMBOLS.secureRandomCharacter())
}
-
- fun setMaximumWordLength(max: Int) = apply {
- maxWordLength = max
- }
-
- fun setSeparator(separator: String) = apply {
- this.separator = separator
- }
-
- fun setCapitalization(capitalizationScheme: CapsType) = apply {
- capsType = capitalizationScheme
+ return numbers.toString()
+ }
+
+ @OptIn(ExperimentalStdlibApi::class)
+ fun create(): Result<String, Throwable> {
+ val wordBank = mutableListOf<String>()
+ val password = StringBuilder()
+
+ if (prependDigits != 0) {
+ password.append(generateRandomNumberSequence(prependDigits))
+ if (isPrependWithSeparator) {
+ password.append(separator)
+ }
}
-
- @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 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)
}
- return numbers.toString()
- }
-
- private fun generateRandomSymbolSequence(numSymbols: Int): String {
- val numbers = StringBuilder(numSymbols)
- for (i in 0 until numSymbols) {
- numbers.append(SYMBOLS.secureRandomCharacter())
+ }
+ if (numDigits != 0) {
+ if (isAppendNumberSeparator) {
+ password.append(separator)
}
- return numbers.toString()
- }
-
- @OptIn(ExperimentalStdlibApi::class)
- fun create(): Result<String, Throwable> {
- val wordBank = mutableListOf<String>()
- 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()
+ password.append(generateRandomNumberSequence(numDigits))
+ }
+ if (numSymbols != 0) {
+ if (isAppendSymbolsSeparator) {
+ password.append(separator)
}
+ password.append(generateRandomSymbolSequence(numSymbols))
+ }
+ password.toString()
}
+ }
- companion object {
+ companion object {
- private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#"
- }
+ 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
index 1e44ab20..ab31892f 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/pwgenxkpwd/XkpwdDictionary.kt
@@ -13,28 +13,28 @@ import java.io.File
class XkpwdDictionary(context: Context) {
- val words: Map<Int, List<String>>
-
- 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"
- }
+ val words: Map<Int, List<String>>
+
+ 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
index 1b6ba6c7..d25a110e 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/services/ClipboardService.kt
@@ -32,155 +32,150 @@ 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
- }
- }
+ 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)
}
-
- return super.onStartCommand(intent, flags, startId)
- }
-
- override fun onBind(intent: Intent?): IBinder? {
- return null
- }
-
- override fun onDestroy() {
- scope.cancel()
- super.onDestroy()
- }
-
- private fun clearClipboard() {
- val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false)
- val clipboard = clipboard
-
- if (clipboard != null) {
- scope.launch {
- d { "Clearing the clipboard" }
- val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
- clipboard.setPrimaryClip(clip)
- if (deepClear) {
- withContext(Dispatchers.IO) {
- repeat(CLIPBOARD_CLEAR_COUNT) {
- val count = (it * 500).toString()
- clipboard.setPrimaryClip(ClipData.newPlainText(count, count))
- }
- }
- }
+ 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()
}
- } 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)
+ }
+ return START_NOT_STICKY
}
- 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<NotificationManager>()
- if (manager != null) {
- manager.createNotificationChannel(serviceChannel)
- } else {
- d { "Failed to create notification channel" }
+ return super.onStartCommand(intent, flags, startId)
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ private fun clearClipboard() {
+ val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false)
+ val clipboard = clipboard
+
+ if (clipboard != null) {
+ scope.launch {
+ d { "Clearing the clipboard" }
+ val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
+ clipboard.setPrimaryClip(clip)
+ if (deepClear) {
+ withContext(Dispatchers.IO) {
+ repeat(CLIPBOARD_CLEAR_COUNT) {
+ val count = (it * 500).toString()
+ clipboard.setPrimaryClip(ClipData.newPlainText(count, count))
}
+ }
}
+ }
+ } else {
+ d { "Cannot get clipboard manager service" }
}
-
- companion object {
-
- const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER"
- const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME"
- private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
- private const val CHANNEL_ID = "NotificationService"
- // Newest Samsung phones now feature a history of up to 30 items. To err on the side of caution,
- // push 35 fake ones.
- private const val CLIPBOARD_CLEAR_COUNT = 35
+ }
+
+ 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<NotificationManager>()
+ 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"
+ // Newest Samsung phones now feature a history of up to 30 items. To err on the side of
+ // caution,
+ // push 35 fake ones.
+ private const val CLIPBOARD_CLEAR_COUNT = 35
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt b/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt
index 35a2502e..ce55fe7b 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/services/OreoAutofillService.kt
@@ -38,108 +38,121 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
@RequiresApi(Build.VERSION_CODES.O)
class OreoAutofillService : AutofillService() {
- companion object {
+ 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",
- )
+ // 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
- }
+ private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L
+ }
- override fun onCreate() {
- super.onCreate()
- cachePublicSuffixList(applicationContext)
- }
+ 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
+ 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)
+ 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
+ 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 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 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 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
+ 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
- )
- )
- }
+ callback.onSuccess(
+ AutofillSaveActivity.makeSaveIntentSender(
+ this,
+ credentials = Credentials(username, password, null),
+ formOrigin = formOrigin
+ )
+ )
+ }
}
fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
fun Context.getCustomSuffixes(): Sequence<String> {
- return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
- ?.splitToSequence('\n')
- ?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
- ?: emptySequence()
+ 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
index 56b8e2e4..2ecd2287 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/services/PasswordExportService.kt
@@ -25,134 +25,131 @@ 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>("uri")
- if (uri != null) {
- val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
-
- if (targetDirectory != null) {
- createNotification()
- exportPasswords(targetDirectory)
- stopSelf()
- return START_NOT_STICKY
- }
- }
- }
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ if (intent != null) {
+ when (intent.action) {
+ ACTION_EXPORT_PASSWORD -> {
+ val uri = intent.getParcelableExtra<Uri>("uri")
+ if (uri != null) {
+ val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
+
+ if (targetDirectory != null) {
+ createNotification()
+ exportPasswords(targetDirectory)
+ stopSelf()
+ return START_NOT_STICKY
}
+ }
}
- return super.onStartCommand(intent, flags, startId)
+ }
}
-
- override fun onBind(intent: Intent?): IBinder? {
- return null
+ 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%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z")))
+ }
+
+ val passDir = targetDirectory.createDirectory("password_store_$dateString")
+
+ if (passDir != null) {
+ copyDirToDir(sourcePassDir, passDir)
}
-
- /**
- * 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%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z")))
- }
-
- val passDir = targetDirectory.createDirectory("password_store_$dateString")
-
- if (passDir != null) {
- copyDirToDir(sourcePassDir, passDir)
- }
+ }
+
+ /**
+ * Copies a password file to a given directory.
+ *
+ * Note: this does not preserve last modified time.
+ *
+ * @param passwordFile password file to copy.
+ * @param targetDirectory target directory to copy password.
+ */
+ private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) {
+ val sourceInputStream = contentResolver.openInputStream(passwordFile.uri)
+ val name = passwordFile.name
+ val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!)
+ if (targetPasswordFile?.exists() == true) {
+ val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri)
+
+ if (destOutputStream != null && sourceInputStream != null) {
+ sourceInputStream.copyTo(destOutputStream, 1024)
+
+ sourceInputStream.close()
+ destOutputStream.close()
+ }
}
-
- /**
- * Copies a password file to a given directory.
- *
- * Note: this does not preserve last modified time.
- *
- * @param passwordFile password file to copy.
- * @param targetDirectory target directory to copy password.
- */
- private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) {
- val sourceInputStream = contentResolver.openInputStream(passwordFile.uri)
- val name = passwordFile.name
- val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!)
- if (targetPasswordFile?.exists() == true) {
- val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri)
-
- if (destOutputStream != null && sourceInputStream != null) {
- sourceInputStream.copyTo(destOutputStream, 1024)
-
- sourceInputStream.close()
- destOutputStream.close()
- }
- }
+ }
+
+ /**
+ * Recursively copies a directory to a destination.
+ *
+ * @param sourceDirectory directory to copy from.
+ * @param targetDirectory directory to copy to.
+ */
+ private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) {
+ sourceDirectory.listFiles().forEach { file ->
+ if (file.isDirectory) {
+ // Create new directory and recurse
+ val newDir = targetDirectory.createDirectory(file.name!!)
+ copyDirToDir(file, newDir!!)
+ } else {
+ copyFileToDir(file, targetDirectory)
+ }
}
-
- /**
- * Recursively copies a directory to a destination.
- *
- * @param sourceDirectory directory to copy from.
- * @param targetDirectory directory to copy to.
- */
- private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) {
- sourceDirectory.listFiles().forEach { file ->
- if (file.isDirectory) {
- // Create new directory and recurse
- val newDir = targetDirectory.createDirectory(file.name!!)
- copyDirToDir(file, newDir!!)
- } else {
- copyFileToDir(file, targetDirectory)
- }
- }
- }
-
- private fun createNotification() {
- createNotificationChannel()
-
- val notification = NotificationCompat.Builder(this, CHANNEL_ID)
- .setContentTitle(getString(R.string.app_name))
- .setContentText(getString(R.string.exporting_passwords))
- .setSmallIcon(R.drawable.ic_round_import_export)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .build()
-
- startForeground(2, notification)
+ }
+
+ private fun 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<NotificationManager>()
+ if (manager != null) {
+ manager.createNotificationChannel(serviceChannel)
+ } else {
+ d { "Failed to create notification channel" }
+ }
}
+ }
- 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<NotificationManager>()
- if (manager != null) {
- manager.createNotificationChannel(serviceChannel)
- } else {
- d { "Failed to create notification channel" }
- }
- }
- }
+ companion object {
- companion object {
-
- const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD"
- private const val CHANNEL_ID = "NotificationService"
- }
+ 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
index 79ee13d5..3a508e45 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/GitSettings.kt
@@ -17,191 +17,168 @@ import java.io.File
import org.eclipse.jgit.transport.URIish
enum class Protocol(val pref: String) {
- Ssh("ssh://"),
- Https("https://"),
- ;
+ Ssh("ssh://"),
+ Https("https://"),
+ ;
- companion object {
+ 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")
- }
+ 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")
- }
+ 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() }
- private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" }
-
- var authMode
- get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
- private set(value) {
- settings.edit {
- putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref)
- }
- }
-
- var url
- get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL)
- private set(value) {
- require(value != null)
- if (value == url)
- return
- settings.edit {
- putString(PreferenceKeys.GIT_REMOTE_URL, value)
- }
- if (PasswordRepository.isInitialized)
- PasswordRepository.addRemote("origin", value, true)
- // When the server changes, remote password, multiplexing support and host key file
- // should be deleted/reset.
- useMultiplexing = true
- encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
- clearSavedHostKey()
- }
-
- var authorName
- get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
- set(value) {
- settings.edit {
- putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value)
- }
- }
-
- var authorEmail
- get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: ""
- set(value) {
- settings.edit {
- putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value)
- }
- }
-
- var branch
- get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH
- private set(value) {
- settings.edit {
- putString(PreferenceKeys.GIT_BRANCH_NAME, value)
- }
- }
-
- var useMultiplexing
- get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true)
- set(value) {
- settings.edit {
- putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value)
- }
- }
-
- var proxyHost
- get() = proxySettings.getString(PreferenceKeys.PROXY_HOST)
- set(value) {
- proxySettings.edit {
- putString(PreferenceKeys.PROXY_HOST, value)
- }
- }
-
- var proxyPort
- get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1)
- set(value) {
- proxySettings.edit {
- putInt(PreferenceKeys.PROXY_PORT, value)
- }
- }
-
- var proxyUsername
- get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
- set(value) {
- proxySettings.edit {
- putString(PreferenceKeys.PROXY_USERNAME, value)
- }
- }
-
- var proxyPassword
- get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD)
- set(value) {
- proxySettings.edit {
- putString(PreferenceKeys.PROXY_PASSWORD, value)
- }
- }
-
- var rebaseOnPull
- get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true)
- set(value) {
- settings.edit {
- putBoolean(PreferenceKeys.REBASE_ON_PULL, value)
- }
- }
-
- sealed class UpdateConnectionSettingsResult {
- class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
- class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) : UpdateConnectionSettingsResult()
- object Valid : UpdateConnectionSettingsResult()
- object FailedToParseUrl : UpdateConnectionSettingsResult()
+ 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() }
+ private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" }
+
+ var authMode
+ get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
+ private set(value) {
+ settings.edit { putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) }
+ }
+
+ var url
+ get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL)
+ private set(value) {
+ require(value != null)
+ if (value == url) return
+ settings.edit { putString(PreferenceKeys.GIT_REMOTE_URL, value) }
+ if (PasswordRepository.isInitialized) PasswordRepository.addRemote("origin", value, true)
+ // When the server changes, remote password, multiplexing support and host key file
+ // should be deleted/reset.
+ useMultiplexing = true
+ encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
+ clearSavedHostKey()
+ }
+
+ var authorName
+ get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
+ set(value) {
+ settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) }
+ }
+
+ var authorEmail
+ get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: ""
+ set(value) {
+ settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) }
+ }
+
+ var branch
+ get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH
+ private set(value) {
+ settings.edit { putString(PreferenceKeys.GIT_BRANCH_NAME, value) }
}
- 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
+ var useMultiplexing
+ get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true)
+ set(value) {
+ settings.edit { putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) }
}
- /**
- * Deletes a previously saved SSH host key
- */
- fun clearSavedHostKey() {
- File(hostKeyPath).delete()
+ var proxyHost
+ get() = proxySettings.getString(PreferenceKeys.PROXY_HOST)
+ set(value) {
+ proxySettings.edit { putString(PreferenceKeys.PROXY_HOST, value) }
}
- /**
- * Returns true if a host key was previously saved
- */
- fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists()
+ var proxyPort
+ get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1)
+ set(value) {
+ proxySettings.edit { putInt(PreferenceKeys.PROXY_PORT, value) }
+ }
+
+ var proxyUsername
+ get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
+ set(value) {
+ proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) }
+ }
+
+ var proxyPassword
+ get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD)
+ set(value) {
+ proxySettings.edit { putString(PreferenceKeys.PROXY_PASSWORD, value) }
+ }
+
+ var rebaseOnPull
+ get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true)
+ set(value) {
+ settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, value) }
+ }
+
+ sealed class UpdateConnectionSettingsResult {
+ class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
+ class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) :
+ UpdateConnectionSettingsResult()
+ object Valid : UpdateConnectionSettingsResult()
+ object FailedToParseUrl : UpdateConnectionSettingsResult()
+ }
+
+ fun updateConnectionSettingsIfValid(
+ newAuthMode: AuthMode,
+ newUrl: String,
+ newBranch: String
+ ): UpdateConnectionSettingsResult {
+ val parsedUrl =
+ runCatching { URIish(newUrl) }.getOrElse {
+ return UpdateConnectionSettingsResult.FailedToParseUrl
+ }
+ val newProtocol =
+ when (parsedUrl.scheme) {
+ in listOf("http", "https") -> Protocol.Https
+ in listOf("ssh", null) -> Protocol.Ssh
+ else -> return UpdateConnectionSettingsResult.FailedToParseUrl
+ }
+ if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank())
+ return UpdateConnectionSettingsResult.MissingUsername(newProtocol)
+
+ val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password)
+ val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey)
+ when {
+ newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> {
+ return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth)
+ }
+ newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> {
+ return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth)
+ }
+ }
+
+ url = newUrl
+ authMode = newAuthMode
+ branch = newBranch
+ return UpdateConnectionSettingsResult.Valid
+ }
+
+ /** Deletes a previously saved SSH host key */
+ fun clearSavedHostKey() {
+ File(hostKeyPath).delete()
+ }
+
+ /** Returns true if a host key was previously saved */
+ fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists()
}
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
index b804d748..a5612603 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/Migrations.kt
@@ -20,108 +20,100 @@ import java.io.File
import java.net.URI
fun runMigrations(context: Context) {
- val sharedPrefs = context.sharedPrefs
- migrateToGitUrlBasedConfig(sharedPrefs)
- migrateToHideAll(sharedPrefs)
- migrateToSshKey(context, sharedPrefs)
- migrateToClipboardHistory(sharedPrefs)
+ val sharedPrefs = context.sharedPrefs
+ migrateToGitUrlBasedConfig(sharedPrefs)
+ migrateToHideAll(sharedPrefs)
+ migrateToSshKey(context, sharedPrefs)
+ migrateToClipboardHistory(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))
+ 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()
+ // 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" }
- }
+ 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)
- }
+ 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)
- }
- }
+ 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) }
+ }
}
private fun migrateToClipboardHistory(sharedPrefs: SharedPreferences) {
- if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) {
- sharedPrefs.edit {
- putBoolean(
- PreferenceKeys.CLEAR_CLIPBOARD_HISTORY,
- sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
- )
- remove(PreferenceKeys.CLEAR_CLIPBOARD_20X)
- }
+ if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) {
+ sharedPrefs.edit {
+ putBoolean(
+ PreferenceKeys.CLEAR_CLIPBOARD_HISTORY,
+ sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
+ )
+ remove(PreferenceKeys.CLEAR_CLIPBOARD_20X)
}
+ }
}
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
index a858a355..ee678de2 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/PasswordSortOrder.kt
@@ -13,37 +13,36 @@ import dev.msfjarvis.aps.util.extensions.base64
import dev.msfjarvis.aps.util.extensions.getString
enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) {
+ FOLDER_FIRST(
+ Comparator { p1: PasswordItem, p2: PasswordItem ->
+ (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true)
+ }
+ ),
+ INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem -> p1.name.compareTo(p2.name, ignoreCase = true) }),
+ RECENTLY_USED(
+ Comparator { p1: PasswordItem, p2: PasswordItem ->
+ val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
+ val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
+ val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
+ when {
+ timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1)
+ timeP1 != null && timeP2 == null -> return@Comparator -1
+ timeP1 == null && timeP2 != null -> return@Comparator 1
+ else -> p1.name.compareTo(p2.name, ignoreCase = true)
+ }
+ }
+ ),
+ FILE_FIRST(
+ Comparator { p1: PasswordItem, p2: PasswordItem ->
+ (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
+ }
+ );
- 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 {
+ companion object {
- @JvmStatic
- fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
- return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name)
- }
+ @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
index 1cdb3d93..7e3166d8 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/settings/PreferenceKeys.kt
@@ -7,85 +7,79 @@ 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"
- @Deprecated(
- message = "Use CLEAR_CLIPBOARD_HISTORY instead",
- replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"),
- )
- const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x"
- const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history"
- const val CLEAR_SAVED_PASS = "clear_saved_pass"
- const val COPY_ON_DECRYPT = "copy_on_decrypt"
- const val ENABLE_DEBUG_LOGGING = "enable_debug_logging"
- const val EXPORT_PASSWORDS = "export_passwords"
- const val FILTER_RECURSIVELY = "filter_recursively"
- const val GENERAL_SHOW_TIME = "general_show_time"
- const val GIT_CONFIG = "git_config"
- const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email"
- const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name"
- 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"
+ const val APP_THEME = "app_theme"
+ const val APP_VERSION = "app_version"
+ const val AUTOFILL_ENABLE = "autofill_enable"
+ const val BIOMETRIC_AUTH = "biometric_auth"
+ @Deprecated(
+ message = "Use CLEAR_CLIPBOARD_HISTORY instead",
+ replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"),
+ )
+ const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x"
+ const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history"
+ const val CLEAR_SAVED_PASS = "clear_saved_pass"
+ const val COPY_ON_DECRYPT = "copy_on_decrypt"
+ const val ENABLE_DEBUG_LOGGING = "enable_debug_logging"
+ const val EXPORT_PASSWORDS = "export_passwords"
+ const val FILTER_RECURSIVELY = "filter_recursively"
+ const val GENERAL_SHOW_TIME = "general_show_time"
+ const val GIT_CONFIG = "git_config"
+ const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email"
+ const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name"
+ 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_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_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_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_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"
+ @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"
- @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(
+ 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"
+ @Deprecated("To be used only in Migrations.kt") const val USE_GENERATED_KEY = "use_generated_key"
- const val PROXY_SETTINGS = "proxy_settings"
- const val PROXY_HOST = "proxy_host"
- const val PROXY_PORT = "proxy_port"
- const val PROXY_USERNAME = "proxy_username"
- const val PROXY_PASSWORD = "proxy_password"
+ const val PROXY_SETTINGS = "proxy_settings"
+ const val PROXY_HOST = "proxy_host"
+ const val PROXY_PORT = "proxy_port"
+ const val PROXY_USERNAME = "proxy_username"
+ const val PROXY_PASSWORD = "proxy_password"
- const val REBASE_ON_PULL = "rebase_on_pull"
+ const val REBASE_ON_PULL = "rebase_on_pull"
}
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
index c0fbb8c4..1ef155a5 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/Otp.kt
@@ -16,58 +16,59 @@ import org.apache.commons.codec.binary.Base32
object Otp {
- private val BASE_32 = Base32()
- private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray()
+ private val BASE_32 = Base32()
+ private val STEAM_ALPHABET = "23456789BCDFGHJKMNPQRTVWXY".toCharArray()
- init {
- check(STEAM_ALPHABET.size == 26)
- }
+ 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())
+ 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"))
}
- // 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)
- }
- }
+ 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
index 8144095d..e787fea5 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/TotpFinder.kt
@@ -5,28 +5,18 @@
package dev.msfjarvis.aps.util.totp
-/**
- * Defines a class that can extract relevant parts of a TOTP URL for use by the app.
- */
+/** 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 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 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 TOTP timeout period. */
+ fun findPeriod(content: String): Long
- /**
- * Get the algorithm for the TOTP secret.
- */
- fun findAlgorithm(content: String): String
+ /** 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
index c205b94b..fa3ff28a 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/totp/UriTotpFinder.kt
@@ -7,60 +7,51 @@ package dev.msfjarvis.aps.util.totp
import android.net.Uri
-/**
- * [Uri] backed TOTP URL parser.
- */
+/** [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 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]
+ }
}
-
- 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"
+ 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")!!
+ }
}
-
- 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
+ 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
+ }
}
-
- 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"
+ 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 {
+ companion object {
- val TOTP_FIELDS = arrayOf(
- "otpauth://totp",
- "totp:"
- )
- }
+ 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
index 0734c1d6..c3998d2a 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/viewmodel/SearchableRepositoryViewModel.kt
@@ -50,425 +50,422 @@ 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 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++
+ 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
+ }
}
- return if (i == filter.length) score else 0
+ j++
+ }
+ return if (i == filter.length) score else 0
}
-private val CaseInsensitiveComparator = Collator.getInstance().apply {
- strength = Collator.PRIMARY
-}
+private val CaseInsensitiveComparator = Collator.getInstance().apply { strength = Collator.PRIMARY }
private fun PasswordItem.Companion.makeComparator(
- typeSortOrder: PasswordSortOrder,
- directoryStructure: DirectoryStructure
+ typeSortOrder: PasswordSortOrder,
+ directoryStructure: DirectoryStructure
): Comparator<PasswordItem> {
- return when (typeSortOrder) {
- PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type }
- // In order to let INDEPENDENT not distinguish between items based on their type, we simply
- // declare them all equal at this stage.
- PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 }
- PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type }
- PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator
+ 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)
- })
+ .then(compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getIdentifierFor(it.file) })
+ .then(compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getUsernameFor(it.file) })
}
val PasswordItem.stableId: String
- get() = file.absolutePath
+ get() = file.absolutePath
enum class FilterMode {
- NoFilter,
- StrictDomain,
- Fuzzy
+ NoFilter,
+ StrictDomain,
+ Fuzzy
}
enum class SearchMode {
- RecursivelyInSubdirectories,
- InCurrentDirectoryOnly
+ RecursivelyInSubdirectories,
+ InCurrentDirectoryOnly
}
enum class ListMode {
- FilesOnly,
- DirectoriesOnly,
- AllEntries
+ 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 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 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 fun updateSearchAction(action: SearchAction) = action.copy(updateCounter = updateCounter)
+
+ private val searchAction =
+ MutableLiveData(
+ makeSearchAction(
+ baseDirectory = root,
+ filter = "",
+ filterMode = FilterMode.NoFilter,
+ searchMode = SearchMode.InCurrentDirectoryOnly,
+ listMode = ListMode.AllEntries
+ )
)
- private val searchActionFlow = searchAction.asFlow().distinctUntilChanged()
-
- data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean)
-
- val searchResult = searchActionFlow
- .mapLatest { searchAction ->
- val listResultFlow = when (searchAction.searchMode) {
- SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory)
- SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory)
+ private val searchActionFlow = searchAction.asFlow().distinctUntilChanged()
+
+ data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean)
+
+ val searchResult =
+ searchActionFlow
+ .mapLatest { searchAction ->
+ val listResultFlow =
+ when (searchAction.searchMode) {
+ SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory)
+ SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory)
+ }
+ val prefilteredResultFlow =
+ when (searchAction.listMode) {
+ ListMode.FilesOnly -> listResultFlow.filter { it.isFile }
+ ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory }
+ ListMode.AllEntries -> listResultFlow
+ }
+ val filterModeToUse = if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode
+ val passwordList =
+ when (filterModeToUse) {
+ FilterMode.NoFilter -> {
+ prefilteredResultFlow.map { it.toPasswordItem() }.toList().sortedWith(itemComparator)
}
- val prefilteredResultFlow = when (searchAction.listMode) {
- ListMode.FilesOnly -> listResultFlow.filter { it.isFile }
- ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory }
- ListMode.AllEntries -> listResultFlow
+ 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()
+ }
}
- 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<Pair<Int, PasswordItem>> { it.first }.thenBy(
- itemComparator
- ) { it.second })
- .map { it.second }
+ FilterMode.Fuzzy -> {
+ prefilteredResultFlow
+ .map {
+ val item = it.toPasswordItem()
+ Pair(item.fuzzyMatch(searchAction.filter), item)
}
+ .filter { it.first > 0 }
+ .toList()
+ .sortedWith(
+ compareByDescending<Pair<Int, PasswordItem>> { it.first }.thenBy(itemComparator) { it.second }
+ )
+ .map { it.second }
}
- SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter)
- }.asLiveData(Dispatchers.IO)
-
- private fun shouldTake(file: File) = with(file) {
- if (showHiddenContents) return true
- if (isDirectory) {
- !isHidden
- } else {
- !isHidden && file.extension == "gpg"
- }
- }
-
- private fun listFiles(dir: File): Flow<File> {
- return dir.listFiles { file -> shouldTake(file) }?.asFlow() ?: emptyFlow()
+ }
+ 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 listFilesRecursively(dir: File): Flow<File> {
- return dir
- // Take top directory even if it is hidden.
- .walkTopDown().onEnter { file -> file == dir || shouldTake(file) }
- .asFlow()
- // Skip the root directory
- .drop(1)
- .map {
- yield()
- it
- }
- .filter { file -> shouldTake(file) }
+ private fun listFiles(dir: File): Flow<File> {
+ return dir.listFiles { file -> shouldTake(file) }?.asFlow() ?: emptyFlow()
+ }
+
+ private fun listFilesRecursively(dir: File): Flow<File> {
+ return dir
+ // Take top directory even if it is hidden.
+ .walkTopDown()
+ .onEnter { file -> file == dir || shouldTake(file) }
+ .asFlow()
+ // Skip the root directory
+ .drop(1)
+ .map {
+ yield()
+ it
+ }
+ .filter { file -> shouldTake(file) }
+ }
+
+ private val _currentDir = MutableLiveData(root)
+ val currentDir = _currentDir as LiveData<File>
+
+ data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?)
+
+ private val navigationStack = Stack<NavigationStackEntry>()
+
+ fun navigateTo(
+ newDirectory: File = root,
+ listMode: ListMode = ListMode.AllEntries,
+ recyclerViewState: Parcelable? = null,
+ pushPreviousLocation: Boolean = true
+ ) {
+ if (!newDirectory.exists()) return
+ require(newDirectory.isDirectory) { "Can only navigate to a directory" }
+ if (pushPreviousLocation) {
+ navigationStack.push(NavigationStackEntry(_currentDir.value!!, recyclerViewState))
}
-
- private val _currentDir = MutableLiveData(root)
- val currentDir = _currentDir as LiveData<File>
-
- data class NavigationStackEntry(val dir: File, val recyclerViewState: Parcelable?)
-
- private val navigationStack = Stack<NavigationStackEntry>()
-
- fun navigateTo(
- newDirectory: File = root,
- listMode: ListMode = ListMode.AllEntries,
- recyclerViewState: Parcelable? = null,
- pushPreviousLocation: Boolean = true
- ) {
- if (!newDirectory.exists()) return
- require(newDirectory.isDirectory) { "Can only navigate to a directory" }
- if (pushPreviousLocation) {
- navigationStack.push(NavigationStackEntry(_currentDir.value!!, recyclerViewState))
- }
- searchAction.postValue(
- makeSearchAction(
- filter = "",
- baseDirectory = newDirectory,
- filterMode = FilterMode.NoFilter,
- searchMode = SearchMode.InCurrentDirectoryOnly,
- listMode = listMode
- )
- )
- _currentDir.postValue(newDirectory)
- }
-
- val canNavigateBack
- get() = navigationStack.isNotEmpty()
-
- /**
- * Navigate back to the last location on the [navigationStack] and restore a cached scroll
- * position if possible.
- *
- * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached.
- */
- fun navigateBack(): Parcelable? {
- if (!canNavigateBack) return null
- val (oldDir, oldRecyclerViewState) = navigationStack.pop()
- navigateTo(oldDir, pushPreviousLocation = false)
- return oldRecyclerViewState
- }
-
- fun reset() {
- navigationStack.clear()
- forceUpdateOnNextSearchAction()
- navigateTo(pushPreviousLocation = false)
- }
-
- fun search(
- filter: String,
- baseDirectory: File? = null,
- filterMode: FilterMode = FilterMode.Fuzzy,
- searchMode: SearchMode? = null,
- listMode: ListMode = ListMode.AllEntries
- ) {
- require(baseDirectory?.isDirectory != false) { "Can only search in a directory" }
- searchAction.postValue(
- makeSearchAction(
- filter = filter,
- baseDirectory = baseDirectory ?: _currentDir.value!!,
- filterMode = filterMode,
- searchMode = searchMode ?: defaultSearchMode,
- listMode = listMode
- )
- )
- }
-
- fun forceRefresh() {
- forceUpdateOnNextSearchAction()
- searchAction.postValue(updateSearchAction(searchAction.value!!))
- }
-
- companion object {
-
- @VisibleForTesting
- fun generateStrictDomainRegex(domain: String): Regex? {
- // Valid domains do not contain path separators.
- if (domain.contains('/'))
- return null
- // Matches the start of a path component, which is either the start of the
- // string or a path separator.
- val prefix = """(?:^|/)"""
- val escapedFilter = Regex.escape(domain.replace("/", ""))
- // Matches either the filter literally or a strict subdomain of the filter term.
- // We allow a lot of freedom in what a subdomain is, as long as it is not an
- // email address.
- val subdomain = """(?:(?:[^/@]+\.)?$escapedFilter)"""
- // Matches the end of a path component, which is either the literal ".gpg" or a
- // path separator.
- val suffix = """(?:\.gpg|/)"""
- // Match any relative path with a component that is a subdomain of the filter.
- return Regex(prefix + subdomain + suffix)
- }
+ searchAction.postValue(
+ makeSearchAction(
+ filter = "",
+ baseDirectory = newDirectory,
+ filterMode = FilterMode.NoFilter,
+ searchMode = SearchMode.InCurrentDirectoryOnly,
+ listMode = listMode
+ )
+ )
+ _currentDir.postValue(newDirectory)
+ }
+
+ val canNavigateBack
+ get() = navigationStack.isNotEmpty()
+
+ /**
+ * Navigate back to the last location on the [navigationStack] and restore a cached scroll
+ * position if possible.
+ *
+ * Returns the old RecyclerView's LinearLayoutManager state as a [Parcelable] if it was cached.
+ */
+ fun navigateBack(): Parcelable? {
+ if (!canNavigateBack) return null
+ val (oldDir, oldRecyclerViewState) = navigationStack.pop()
+ navigateTo(oldDir, pushPreviousLocation = false)
+ return oldRecyclerViewState
+ }
+
+ fun reset() {
+ navigationStack.clear()
+ forceUpdateOnNextSearchAction()
+ navigateTo(pushPreviousLocation = false)
+ }
+
+ fun search(
+ filter: String,
+ baseDirectory: File? = null,
+ filterMode: FilterMode = FilterMode.Fuzzy,
+ searchMode: SearchMode? = null,
+ listMode: ListMode = ListMode.AllEntries
+ ) {
+ require(baseDirectory?.isDirectory != false) { "Can only search in a directory" }
+ searchAction.postValue(
+ makeSearchAction(
+ filter = filter,
+ baseDirectory = baseDirectory ?: _currentDir.value!!,
+ filterMode = filterMode,
+ searchMode = searchMode ?: defaultSearchMode,
+ listMode = listMode
+ )
+ )
+ }
+
+ fun forceRefresh() {
+ forceUpdateOnNextSearchAction()
+ searchAction.postValue(updateSearchAction(searchAction.value!!))
+ }
+
+ companion object {
+
+ @VisibleForTesting
+ fun generateStrictDomainRegex(domain: String): Regex? {
+ // Valid domains do not contain path separators.
+ if (domain.contains('/')) return null
+ // Matches the start of a path component, which is either the start of the
+ // string or a path separator.
+ val prefix = """(?:^|/)"""
+ val escapedFilter = Regex.escape(domain.replace("/", ""))
+ // Matches either the filter literally or a strict subdomain of the filter term.
+ // We allow a lot of freedom in what a subdomain is, as long as it is not an
+ // email address.
+ val subdomain = """(?:(?:[^/@]+\.)?$escapedFilter)"""
+ // Matches the end of a path component, which is either the literal ".gpg" or a
+ // path separator.
+ val suffix = """(?:\.gpg|/)"""
+ // Match any relative path with a component that is a subdomain of the filter.
+ return Regex(prefix + subdomain + suffix)
}
+ }
}
private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>() {
- override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
- oldItem.file.absolutePath == newItem.file.absolutePath
+ override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
+ oldItem.file.absolutePath == newItem.file.absolutePath
- override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
- oldItem == newItem
+ override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem
}
open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
- private val layoutRes: Int,
- private val viewHolderCreator: (view: View) -> T,
- private val viewHolderBinder: T.(item: PasswordItem) -> Unit
+ private val layoutRes: Int,
+ private val viewHolderCreator: (view: View) -> T,
+ private val viewHolderBinder: T.(item: PasswordItem) -> Unit
) : ListAdapter<PasswordItem, T>(PasswordItemDiffCallback), PopupTextProvider {
- fun <T : ItemDetailsLookup<String>> makeSelectable(
- recyclerView: RecyclerView,
- itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T
- ) {
- selectionTracker = SelectionTracker.Builder(
- "SearchableRepositoryAdapter",
- recyclerView,
- itemKeyProvider,
- itemDetailsLookupCreator(recyclerView),
- StorageStrategy.createStringStorage()
- ).withSelectionPredicate(SelectionPredicates.createSelectAnything()).build().apply {
- addObserver(object : SelectionTracker.SelectionObserver<String>() {
- override fun onSelectionChanged() {
- this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(
- requireSelectionTracker().selection
- )
- }
- })
+ fun <T : ItemDetailsLookup<String>> makeSelectable(
+ recyclerView: RecyclerView,
+ itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T
+ ) {
+ selectionTracker =
+ SelectionTracker.Builder(
+ "SearchableRepositoryAdapter",
+ recyclerView,
+ itemKeyProvider,
+ itemDetailsLookupCreator(recyclerView),
+ StorageStrategy.createStringStorage()
+ )
+ .withSelectionPredicate(SelectionPredicates.createSelectAnything())
+ .build()
+ .apply {
+ addObserver(
+ object : SelectionTracker.SelectionObserver<String>() {
+ override fun onSelectionChanged() {
+ this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(requireSelectionTracker().selection)
+ }
+ }
+ )
}
+ }
+
+ private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null
+ open fun onItemClicked(listener: (holder: T, item: PasswordItem) -> Unit): SearchableRepositoryAdapter<T> {
+ check(onItemClickedListener == null) { "Only a single listener can be registered for onItemClicked" }
+ onItemClickedListener = listener
+ return this
+ }
+
+ private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null
+ open fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): SearchableRepositoryAdapter<T> {
+ check(onSelectionChangedListener == null) { "Only a single listener can be registered for onSelectionChanged" }
+ onSelectionChangedListener = listener
+ return this
+ }
+
+ private val itemKeyProvider =
+ object : ItemKeyProvider<String>(SCOPE_MAPPED) {
+ override fun getKey(position: Int) = getItem(position).stableId
+
+ override fun getPosition(key: String) =
+ (0 until itemCount).firstOrNull { getItem(it).stableId == key } ?: RecyclerView.NO_POSITION
}
- private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null
- open fun onItemClicked(listener: (holder: T, item: PasswordItem) -> Unit): SearchableRepositoryAdapter<T> {
- check(onItemClickedListener == null) { "Only a single listener can be registered for onItemClicked" }
- onItemClickedListener = listener
- return this
- }
-
- private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null
- open fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): SearchableRepositoryAdapter<T> {
- check(onSelectionChangedListener == null) { "Only a single listener can be registered for onSelectionChanged" }
- onSelectionChangedListener = listener
- return this
- }
-
- private val itemKeyProvider = object : ItemKeyProvider<String>(SCOPE_MAPPED) {
- override fun getKey(position: Int) = getItem(position).stableId
+ private var selectionTracker: SelectionTracker<String>? = null
+ fun requireSelectionTracker() = selectionTracker!!
- override fun getPosition(key: String) =
- (0 until itemCount).firstOrNull { getItem(it).stableId == key }
- ?: RecyclerView.NO_POSITION
- }
-
- private var selectionTracker: SelectionTracker<String>? = null
- fun requireSelectionTracker() = selectionTracker!!
+ private val selectedFiles
+ get() = requireSelectionTracker().selection.map { File(it) }
- private val selectedFiles
- get() = requireSelectionTracker().selection.map { File(it) }
+ fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() }
- fun getSelectedItems() = selectedFiles.map { it.toPasswordItem() }
+ fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath)
- 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 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 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())
- }
+ final override fun getPopupText(position: Int): String {
+ return getItem(position).name[0].toString().toUpperCase(Locale.getDefault())
+ }
}
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
index f223cc79..ab9f3d12 100644
--- 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
@@ -29,8 +29,8 @@ 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.autofill.AutofillResponseBuilder
import dev.msfjarvis.aps.util.extensions.viewBinding
import java.util.concurrent.ExecutionException
import kotlin.coroutines.resume
@@ -40,121 +40,112 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-suspend fun <T> Task<T>.suspendableAwait() = suspendCoroutine<T> { cont ->
- addOnSuccessListener { result: T ->
- cont.resume(result)
- }
+suspend fun <T> Task<T>.suspendableAwait() =
+ suspendCoroutine<T> { 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)
+ // 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 {
+ companion object {
- private var fillOtpFromSmsRequestCode = 1
+ 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 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
- }
+ 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 val binding by viewBinding(ActivityOreoAutofillSmsBinding::inflate)
- private lateinit var clientState: Bundle
+ 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 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
+ 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()
- }
- }
+ 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()
- }
- }
+ // 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 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()
- }
+ 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/test/java/dev/msfjarvis/aps/data/password/PasswordEntryTest.kt b/app/src/test/java/dev/msfjarvis/aps/data/password/PasswordEntryTest.kt
index 22ccb8ea..afbe9289 100644
--- a/app/src/test/java/dev/msfjarvis/aps/data/password/PasswordEntryTest.kt
+++ b/app/src/test/java/dev/msfjarvis/aps/data/password/PasswordEntryTest.kt
@@ -17,169 +17,179 @@ import org.junit.Test
class PasswordEntryTest {
- private fun makeEntry(content: String) = PasswordEntry(content, testFinder)
-
- @Test fun testGetPassword() {
- assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
- assertEquals("fooooo", makeEntry("fooooo\nbla").password)
- assertEquals("fooooo", makeEntry("fooooo\n").password)
- assertEquals("fooooo", makeEntry("fooooo").password)
- assertEquals("", makeEntry("\nblubb\n").password)
- assertEquals("", makeEntry("\nblubb").password)
- assertEquals("", makeEntry("\n").password)
- assertEquals("", makeEntry("").password)
- for (field in PasswordEntry.PASSWORD_FIELDS) {
- assertEquals("fooooo", makeEntry("\n$field fooooo").password)
- assertEquals("fooooo", makeEntry("\n${field.toUpperCase()} fooooo").password)
- assertEquals("fooooo", makeEntry("GOPASS-SECRET-1.0\n$field fooooo").password)
- assertEquals("fooooo", makeEntry("someFirstLine\nUsername: bar\n$field fooooo").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("blubb", makeEntry("blubb\npassword: foo").extraContent)
- assertEquals("blubb", makeEntry("password: foo\nblubb").extraContent)
- assertEquals("blubb\nusername: bar", makeEntry("blubb\npassword: foo\nusername: bar").extraContent)
- assertEquals("", makeEntry("\n").extraContent)
- assertEquals("", makeEntry("").extraContent)
+ private fun makeEntry(content: String) = PasswordEntry(content, testFinder)
+
+ @Test
+ fun testGetPassword() {
+ assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
+ assertEquals("fooooo", makeEntry("fooooo\nbla").password)
+ assertEquals("fooooo", makeEntry("fooooo\n").password)
+ assertEquals("fooooo", makeEntry("fooooo").password)
+ assertEquals("", makeEntry("\nblubb\n").password)
+ assertEquals("", makeEntry("\nblubb").password)
+ assertEquals("", makeEntry("\n").password)
+ assertEquals("", makeEntry("").password)
+ for (field in PasswordEntry.PASSWORD_FIELDS) {
+ assertEquals("fooooo", makeEntry("\n$field fooooo").password)
+ assertEquals("fooooo", makeEntry("\n${field.toUpperCase()} fooooo").password)
+ assertEquals("fooooo", makeEntry("GOPASS-SECRET-1.0\n$field fooooo").password)
+ assertEquals("fooooo", makeEntry("someFirstLine\nUsername: bar\n$field fooooo").password)
}
-
- @Test fun parseExtraContentWithoutAuth() {
- var entry = makeEntry("username: abc\npassword: abc\ntest: abcdef")
- assertEquals(1, entry.extraContentMap.size)
- assertTrue(entry.extraContentMap.containsKey("test"))
- assertEquals("abcdef", entry.extraContentMap["test"])
-
- entry = makeEntry("username: abc\npassword: abc\ntest: :abcdef:")
- assertEquals(1, entry.extraContentMap.size)
- assertTrue(entry.extraContentMap.containsKey("test"))
- assertEquals(":abcdef:", entry.extraContentMap["test"])
-
- entry = makeEntry("username: abc\npassword: abc\ntest : ::abc:def::")
- assertEquals(1, entry.extraContentMap.size)
- assertTrue(entry.extraContentMap.containsKey("test"))
- assertEquals("::abc:def::", entry.extraContentMap["test"])
-
- entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\ntest2: ghijkl")
- assertEquals(2, entry.extraContentMap.size)
- assertTrue(entry.extraContentMap.containsKey("test2"))
- assertEquals("ghijkl", entry.extraContentMap["test2"])
-
- entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\n: ghijkl\n mnopqr:")
- assertEquals(2, entry.extraContentMap.size)
- assertTrue(entry.extraContentMap.containsKey("Extra Content"))
- assertEquals(": ghijkl\n mnopqr:", entry.extraContentMap["Extra Content"])
-
- entry = makeEntry("username: abc\npassword: abc\n:\n\n")
- assertEquals(1, entry.extraContentMap.size)
- assertTrue(entry.extraContentMap.containsKey("Extra Content"))
- assertEquals(":", entry.extraContentMap["Extra Content"])
+ }
+
+ @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("blubb", makeEntry("blubb\npassword: foo").extraContent)
+ assertEquals("blubb", makeEntry("password: foo\nblubb").extraContent)
+ assertEquals("blubb\nusername: bar", makeEntry("blubb\npassword: foo\nusername: bar").extraContent)
+ assertEquals("", makeEntry("\n").extraContent)
+ assertEquals("", makeEntry("").extraContent)
+ }
+
+ @Test
+ fun parseExtraContentWithoutAuth() {
+ var entry = makeEntry("username: abc\npassword: abc\ntest: abcdef")
+ assertEquals(1, entry.extraContentMap.size)
+ assertTrue(entry.extraContentMap.containsKey("test"))
+ assertEquals("abcdef", entry.extraContentMap["test"])
+
+ entry = makeEntry("username: abc\npassword: abc\ntest: :abcdef:")
+ assertEquals(1, entry.extraContentMap.size)
+ assertTrue(entry.extraContentMap.containsKey("test"))
+ assertEquals(":abcdef:", entry.extraContentMap["test"])
+
+ entry = makeEntry("username: abc\npassword: abc\ntest : ::abc:def::")
+ assertEquals(1, entry.extraContentMap.size)
+ assertTrue(entry.extraContentMap.containsKey("test"))
+ assertEquals("::abc:def::", entry.extraContentMap["test"])
+
+ entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\ntest2: ghijkl")
+ assertEquals(2, entry.extraContentMap.size)
+ assertTrue(entry.extraContentMap.containsKey("test2"))
+ assertEquals("ghijkl", entry.extraContentMap["test2"])
+
+ entry = makeEntry("username: abc\npassword: abc\ntest: abcdef\n: ghijkl\n mnopqr:")
+ assertEquals(2, entry.extraContentMap.size)
+ assertTrue(entry.extraContentMap.containsKey("Extra Content"))
+ assertEquals(": ghijkl\n mnopqr:", entry.extraContentMap["Extra Content"])
+
+ entry = makeEntry("username: abc\npassword: abc\n:\n\n")
+ assertEquals(1, entry.extraContentMap.size)
+ assertTrue(entry.extraContentMap.containsKey("Extra Content"))
+ assertEquals(":", entry.extraContentMap["Extra Content"])
+ }
+
+ @Test
+ fun testGetUsername() {
+ for (field in PasswordEntry.USERNAME_FIELDS) {
+ assertEquals("username", makeEntry("\n$field username").username)
+ assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username)
}
-
- @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())
+ }
+
+ // https://github.com/android-password-store/Android-Password-Store/issues/1190
+ @Test
+ fun extraContentWithMultipleUsernameFields() {
+ val entry = makeEntry("pass\nuser: user\nid: id\n$TOTP_URI")
+ assertTrue(entry.hasExtraContent())
+ assertTrue(entry.hasTotp())
+ assertTrue(entry.hasUsername())
+ assertEquals("pass", entry.password)
+ assertEquals("user", entry.username)
+ assertEquals("id: id", entry.extraContentWithoutAuthData)
+ }
+
+ companion object {
+
+ const val TOTP_URI =
+ "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
+
+ // This implementation is hardcoded for the URI above.
+ val testFinder =
+ object : TotpFinder {
+ override fun findSecret(content: String): String {
+ return "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"
}
- 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())
- }
-
- // https://github.com/android-password-store/Android-Password-Store/issues/1190
- @Test fun extraContentWithMultipleUsernameFields() {
- val entry = makeEntry("pass\nuser: user\nid: id\n$TOTP_URI")
- assertTrue(entry.hasExtraContent())
- assertTrue(entry.hasTotp())
- assertTrue(entry.hasUsername())
- assertEquals("pass", entry.password)
- assertEquals("user", entry.username)
- assertEquals("id: id", entry.extraContentWithoutAuthData)
- }
-
- companion object {
- const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
-
- // This implementation is hardcoded for the URI above.
- val testFinder = object : TotpFinder {
- override fun findSecret(content: String): String {
- return "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"
- }
-
- override fun findDigits(content: String): String {
- return "6"
- }
+ override fun findDigits(content: String): String {
+ return "6"
+ }
- override fun findPeriod(content: String): Long {
- return 30
- }
+ override fun findPeriod(content: String): Long {
+ return 30
+ }
- override fun findAlgorithm(content: String): String {
- return "SHA1"
- }
+ override fun findAlgorithm(content: String): String {
+ return "SHA1"
}
- }
+ }
+ }
}
diff --git a/app/src/test/java/dev/msfjarvis/aps/util/crypto/GpgIdentifierTest.kt b/app/src/test/java/dev/msfjarvis/aps/util/crypto/GpgIdentifierTest.kt
index fb2a528e..9c36afc1 100644
--- a/app/src/test/java/dev/msfjarvis/aps/util/crypto/GpgIdentifierTest.kt
+++ b/app/src/test/java/dev/msfjarvis/aps/util/crypto/GpgIdentifierTest.kt
@@ -6,38 +6,38 @@
package dev.msfjarvis.aps.util.crypto
import kotlin.test.Ignore
+import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
-import kotlin.test.Test
class GpgIdentifierTest {
- @Test
- fun `parses hexadecimal key id without leading 0x`() {
- val identifier = GpgIdentifier.fromString("79E8208280490C77")
- assertNotNull(identifier)
- assertTrue { identifier is GpgIdentifier.KeyId }
- }
+ @Test
+ fun `parses hexadecimal key id without leading 0x`() {
+ val identifier = GpgIdentifier.fromString("79E8208280490C77")
+ assertNotNull(identifier)
+ assertTrue { identifier is GpgIdentifier.KeyId }
+ }
- @Test
- fun `parses hexadecimal key id`() {
- val identifier = GpgIdentifier.fromString("0x79E8208280490C77")
- assertNotNull(identifier)
- assertTrue { identifier is GpgIdentifier.KeyId }
- }
+ @Test
+ fun `parses hexadecimal key id`() {
+ val identifier = GpgIdentifier.fromString("0x79E8208280490C77")
+ assertNotNull(identifier)
+ assertTrue { identifier is GpgIdentifier.KeyId }
+ }
- @Test
- fun `parses email as user id`() {
- val identifier = GpgIdentifier.fromString("aps@msfjarvis.dev")
- assertNotNull(identifier)
- assertTrue { identifier is GpgIdentifier.UserId }
- }
+ @Test
+ fun `parses email as user id`() {
+ val identifier = GpgIdentifier.fromString("aps@msfjarvis.dev")
+ assertNotNull(identifier)
+ assertTrue { identifier is GpgIdentifier.UserId }
+ }
- @Test
- @Ignore("OpenKeychain can't yet handle these so we don't either")
- fun `parses non-email user id`() {
- val identifier = GpgIdentifier.fromString("john.doe")
- assertNotNull(identifier)
- assertTrue { identifier is GpgIdentifier.UserId }
- }
+ @Test
+ @Ignore("OpenKeychain can't yet handle these so we don't either")
+ fun `parses non-email user id`() {
+ val identifier = GpgIdentifier.fromString("john.doe")
+ assertNotNull(identifier)
+ assertTrue { identifier is GpgIdentifier.UserId }
+ }
}
diff --git a/app/src/test/java/dev/msfjarvis/aps/util/totp/OtpTest.kt b/app/src/test/java/dev/msfjarvis/aps/util/totp/OtpTest.kt
index b3ebc6af..d41c3be9 100644
--- a/app/src/test/java/dev/msfjarvis/aps/util/totp/OtpTest.kt
+++ b/app/src/test/java/dev/msfjarvis/aps/util/totp/OtpTest.kt
@@ -13,44 +13,61 @@ import org.junit.Test
class OtpTest {
- @Test
- fun testOtpGeneration6Digits() {
- assertEquals("953550", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333298159 / (1000 * 30), "SHA1", "6").get())
- assertEquals("275379", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333571918 / (1000 * 30), "SHA1", "6").get())
- assertEquals("867507", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333600517 / (1000 * 57), "SHA1", "6").get())
- }
-
- @Test
- fun testOtpGeneration10Digits() {
- assertEquals("0740900914", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333655044 / (1000 * 30), "SHA1", "10").get())
- assertEquals("0070632029", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333691405 / (1000 * 30), "SHA1", "10").get())
- assertEquals("1017265882", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333728893 / (1000 * 83), "SHA1", "10").get())
- }
-
- @Test
- fun testOtpGenerationIllegalInput() {
- assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA0", "10").get())
- assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "a").get())
- assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "5").get())
- assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "11").get())
- assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAB", 10000, "SHA1", "6").get())
- }
-
- @Test
- fun testOtpGenerationUnusualSecrets() {
- assertEquals("127764", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAAAAA", 1593367111963 / (1000 * 30), "SHA1", "6").get())
- assertEquals("047515", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAA", 1593367171420 / (1000 * 30), "SHA1", "6").get())
- }
-
- @Test
- fun testOtpGenerationUnpaddedSecrets() {
- // Secret was generated with `echo 'string with some padding needed' | base32`
- // We don't care for the resultant OTP's actual value, we just want both the padded and
- // unpadded variant to generate the same one.
- val unpaddedOtp = Otp.calculateCode("ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA", 1593367171420 / (1000 * 30), "SHA1", "6").get()
- val paddedOtp = Otp.calculateCode("ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA====", 1593367171420 / (1000 * 30), "SHA1", "6").get()
- assertNotNull(unpaddedOtp)
- assertNotNull(paddedOtp)
- assertEquals(unpaddedOtp, paddedOtp)
- }
+ @Test
+ fun testOtpGeneration6Digits() {
+ assertEquals("953550", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333298159 / (1000 * 30), "SHA1", "6").get())
+ assertEquals("275379", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333571918 / (1000 * 30), "SHA1", "6").get())
+ assertEquals("867507", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333600517 / (1000 * 57), "SHA1", "6").get())
+ }
+
+ @Test
+ fun testOtpGeneration10Digits() {
+ assertEquals("0740900914", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333655044 / (1000 * 30), "SHA1", "10").get())
+ assertEquals("0070632029", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333691405 / (1000 * 30), "SHA1", "10").get())
+ assertEquals("1017265882", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333728893 / (1000 * 83), "SHA1", "10").get())
+ }
+
+ @Test
+ fun testOtpGenerationIllegalInput() {
+ assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA0", "10").get())
+ assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "a").get())
+ assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "5").get())
+ assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXP", 10000, "SHA1", "11").get())
+ assertNull(Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAB", 10000, "SHA1", "6").get())
+ }
+
+ @Test
+ fun testOtpGenerationUnusualSecrets() {
+ assertEquals(
+ "127764",
+ Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAAAAA", 1593367111963 / (1000 * 30), "SHA1", "6").get()
+ )
+ assertEquals("047515", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAA", 1593367171420 / (1000 * 30), "SHA1", "6").get())
+ }
+
+ @Test
+ fun testOtpGenerationUnpaddedSecrets() {
+ // Secret was generated with `echo 'string with some padding needed' | base32`
+ // We don't care for the resultant OTP's actual value, we just want both the padded and
+ // unpadded variant to generate the same one.
+ val unpaddedOtp =
+ Otp.calculateCode(
+ "ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA",
+ 1593367171420 / (1000 * 30),
+ "SHA1",
+ "6"
+ )
+ .get()
+ val paddedOtp =
+ Otp.calculateCode(
+ "ON2HE2LOM4QHO2LUNAQHG33NMUQHAYLEMRUW4ZZANZSWKZDFMQFA====",
+ 1593367171420 / (1000 * 30),
+ "SHA1",
+ "6"
+ )
+ .get()
+ assertNotNull(unpaddedOtp)
+ assertNotNull(paddedOtp)
+ assertEquals(unpaddedOtp, paddedOtp)
+ }
}
diff --git a/autofill-parser/build.gradle.kts b/autofill-parser/build.gradle.kts
index ba51da3c..2473e3a6 100644
--- a/autofill-parser/build.gradle.kts
+++ b/autofill-parser/build.gradle.kts
@@ -4,34 +4,28 @@
*/
plugins {
- id("com.android.library")
- id("com.vanniktech.maven.publish")
- kotlin("android")
- `aps-plugin`
+ id("com.android.library")
+ id("com.vanniktech.maven.publish")
+ kotlin("android")
+ `aps-plugin`
}
android {
- defaultConfig {
- versionCode = 2
- versionName = "2.0"
- consumerProguardFiles("consumer-rules.pro")
- }
+ defaultConfig {
+ versionCode = 2
+ versionName = "2.0"
+ consumerProguardFiles("consumer-rules.pro")
+ }
- kotlin {
- explicitApi()
- }
+ kotlin { explicitApi() }
- kotlinOptions {
- freeCompilerArgs = freeCompilerArgs + listOf(
- "-Xexplicit-api=strict"
- )
- }
+ kotlinOptions { freeCompilerArgs = freeCompilerArgs + listOf("-Xexplicit-api=strict") }
}
dependencies {
- compileOnly(Dependencies.AndroidX.annotation)
- implementation(Dependencies.AndroidX.autofill)
- implementation(Dependencies.Kotlin.Coroutines.android)
- implementation(Dependencies.Kotlin.Coroutines.core)
- implementation(Dependencies.ThirdParty.timberkt)
+ compileOnly(Dependencies.AndroidX.annotation)
+ implementation(Dependencies.AndroidX.autofill)
+ implementation(Dependencies.Kotlin.Coroutines.android)
+ implementation(Dependencies.Kotlin.Coroutines.core)
+ implementation(Dependencies.ThirdParty.timberkt)
}
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt
index 248db2c7..fb951cf8 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillFormParser.kt
@@ -19,41 +19,40 @@ import com.github.ajalt.timberkt.d
*/
public sealed class FormOrigin(public open val identifier: String) {
- public data class Web(override val identifier: String) : FormOrigin(identifier)
- public data class App(override val identifier: String) : FormOrigin(identifier)
-
- public companion object {
-
- private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier"
- private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier"
-
- public fun fromBundle(bundle: Bundle): FormOrigin? {
- val webIdentifier = bundle.getString(BUNDLE_KEY_WEB_IDENTIFIER)
- if (webIdentifier != null) {
- return Web(webIdentifier)
- } else {
- return App(bundle.getString(BUNDLE_KEY_APP_IDENTIFIER) ?: return null)
- }
- }
+ public data class Web(override val identifier: String) : FormOrigin(identifier)
+ public data class App(override val identifier: String) : FormOrigin(identifier)
+
+ public companion object {
+
+ private const val BUNDLE_KEY_WEB_IDENTIFIER = "webIdentifier"
+ private const val BUNDLE_KEY_APP_IDENTIFIER = "appIdentifier"
+
+ public fun fromBundle(bundle: Bundle): FormOrigin? {
+ val webIdentifier = bundle.getString(BUNDLE_KEY_WEB_IDENTIFIER)
+ if (webIdentifier != null) {
+ return Web(webIdentifier)
+ } else {
+ return App(bundle.getString(BUNDLE_KEY_APP_IDENTIFIER) ?: return null)
+ }
+ }
+ }
+
+ public fun getPrettyIdentifier(context: Context, untrusted: Boolean = true): String =
+ when (this) {
+ is Web -> identifier
+ is App -> {
+ val info = context.packageManager.getApplicationInfo(identifier, PackageManager.GET_META_DATA)
+ val label = context.packageManager.getApplicationLabel(info)
+ if (untrusted) "“$label”" else "$label"
+ }
}
- public fun getPrettyIdentifier(context: Context, untrusted: Boolean = true): String =
- when (this) {
- is Web -> identifier
- is App -> {
- val info = context.packageManager.getApplicationInfo(
- identifier, PackageManager.GET_META_DATA
- )
- val label = context.packageManager.getApplicationLabel(info)
- if (untrusted) "“$label”" else "$label"
- }
- }
-
- public fun toBundle(): Bundle = Bundle().apply {
- when (this@FormOrigin) {
- is Web -> putString(BUNDLE_KEY_WEB_IDENTIFIER, identifier)
- is App -> putString(BUNDLE_KEY_APP_IDENTIFIER, identifier)
- }
+ public fun toBundle(): Bundle =
+ Bundle().apply {
+ when (this@FormOrigin) {
+ is Web -> putString(BUNDLE_KEY_WEB_IDENTIFIER, identifier)
+ is App -> putString(BUNDLE_KEY_APP_IDENTIFIER, identifier)
+ }
}
}
@@ -62,126 +61,123 @@ public sealed class FormOrigin(public open val identifier: String) {
*/
@RequiresApi(Build.VERSION_CODES.O)
private class AutofillFormParser(
- context: Context,
- structure: AssistStructure,
- isManualRequest: Boolean,
- private val customSuffixes: Sequence<String>
+ context: Context,
+ structure: AssistStructure,
+ isManualRequest: Boolean,
+ private val customSuffixes: Sequence<String>
) {
- companion object {
- private val SUPPORTED_SCHEMES = listOf("http", "https")
- }
+ companion object {
+ private val SUPPORTED_SCHEMES = listOf("http", "https")
+ }
- private val relevantFields = mutableListOf<FormField>()
- val ignoredIds = mutableListOf<AutofillId>()
- private var fieldIndex = 0
+ private val relevantFields = mutableListOf<FormField>()
+ val ignoredIds = mutableListOf<AutofillId>()
+ private var fieldIndex = 0
- private var appPackage = structure.activityComponent.packageName
+ private var appPackage = structure.activityComponent.packageName
- private val trustedBrowserInfo =
- getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
- val saveFlags = trustedBrowserInfo?.saveFlags
+ private val trustedBrowserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
+ val saveFlags = trustedBrowserInfo?.saveFlags
- private val webOrigins = mutableSetOf<String>()
+ private val webOrigins = mutableSetOf<String>()
- init {
- d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" }
- parseStructure(structure)
- }
+ init {
+ d { "Request from $appPackage (${computeCertificatesHash(context, appPackage)})" }
+ parseStructure(structure)
+ }
- val scenario = detectFieldsToFill(isManualRequest)
- val formOrigin = determineFormOrigin(context)
+ val scenario = detectFieldsToFill(isManualRequest)
+ val formOrigin = determineFormOrigin(context)
- init {
- d { "Origin: $formOrigin" }
- }
+ init {
+ d { "Origin: $formOrigin" }
+ }
- private fun parseStructure(structure: AssistStructure) {
- for (i in 0 until structure.windowNodeCount) {
- visitFormNode(structure.getWindowNodeAt(i).rootViewNode)
- }
+ private fun parseStructure(structure: AssistStructure) {
+ for (i in 0 until structure.windowNodeCount) {
+ visitFormNode(structure.getWindowNodeAt(i).rootViewNode)
}
-
- private fun visitFormNode(node: AssistStructure.ViewNode, inheritedWebOrigin: String? = null) {
- trackOrigin(node)
- val field =
- if (trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.WebView) {
- FormField(node, fieldIndex, true, inheritedWebOrigin)
- } else {
- check(inheritedWebOrigin == null)
- FormField(node, fieldIndex, false)
- }
- if (field.relevantField) {
- d { "Relevant: $field" }
- relevantFields.add(field)
- fieldIndex++
- } else {
- d { "Ignored : $field" }
- ignoredIds.add(field.autofillId)
- }
- for (i in 0 until node.childCount) {
- visitFormNode(node.getChildAt(i), field.webOriginToPassDown)
- }
+ }
+
+ private fun visitFormNode(node: AssistStructure.ViewNode, inheritedWebOrigin: String? = null) {
+ trackOrigin(node)
+ val field =
+ if (trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.WebView) {
+ FormField(node, fieldIndex, true, inheritedWebOrigin)
+ } else {
+ check(inheritedWebOrigin == null)
+ FormField(node, fieldIndex, false)
+ }
+ if (field.relevantField) {
+ d { "Relevant: $field" }
+ relevantFields.add(field)
+ fieldIndex++
+ } else {
+ d { "Ignored : $field" }
+ ignoredIds.add(field.autofillId)
+ }
+ for (i in 0 until node.childCount) {
+ visitFormNode(node.getChildAt(i), field.webOriginToPassDown)
}
+ }
- private fun detectFieldsToFill(isManualRequest: Boolean) = autofillStrategy.match(
- relevantFields,
- singleOriginMode = trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.None,
- isManualRequest = isManualRequest
+ private fun detectFieldsToFill(isManualRequest: Boolean) =
+ autofillStrategy.match(
+ relevantFields,
+ singleOriginMode = trustedBrowserInfo?.multiOriginMethod == BrowserMultiOriginMethod.None,
+ isManualRequest = isManualRequest
)
- private fun trackOrigin(node: AssistStructure.ViewNode) {
- if (trustedBrowserInfo == null) return
- node.webOrigin?.let {
- if (it !in webOrigins) {
- d { "Origin encountered: $it" }
- webOrigins.add(it)
- }
- }
+ private fun trackOrigin(node: AssistStructure.ViewNode) {
+ if (trustedBrowserInfo == null) return
+ node.webOrigin?.let {
+ if (it !in webOrigins) {
+ d { "Origin encountered: $it" }
+ webOrigins.add(it)
+ }
}
-
- private fun webOriginToFormOrigin(context: Context, origin: String): FormOrigin? {
- val uri = Uri.parse(origin) ?: return null
- val scheme = uri.scheme ?: return null
- if (scheme !in SUPPORTED_SCHEMES) return null
- val host = uri.host ?: return null
- return FormOrigin.Web(getPublicSuffixPlusOne(context, host, customSuffixes))
+ }
+
+ private fun webOriginToFormOrigin(context: Context, origin: String): FormOrigin? {
+ val uri = Uri.parse(origin) ?: return null
+ val scheme = uri.scheme ?: return null
+ if (scheme !in SUPPORTED_SCHEMES) return null
+ val host = uri.host ?: return null
+ return FormOrigin.Web(getPublicSuffixPlusOne(context, host, customSuffixes))
+ }
+
+ private fun determineFormOrigin(context: Context): FormOrigin? {
+ if (scenario == null) return null
+ if (trustedBrowserInfo == null || webOrigins.isEmpty()) {
+ // Security assumption: If a trusted browser includes no web origin in the provided
+ // AssistStructure, then the form is a native browser form (e.g. for a sync password).
+ // TODO: Support WebViews in apps via Digital Asset Links
+ // See:
+ // https://developer.android.com/reference/android/service/autofill/AutofillService#web-security
+ return FormOrigin.App(appPackage)
}
-
- private fun determineFormOrigin(context: Context): FormOrigin? {
- if (scenario == null) return null
- if (trustedBrowserInfo == null || webOrigins.isEmpty()) {
- // Security assumption: If a trusted browser includes no web origin in the provided
- // AssistStructure, then the form is a native browser form (e.g. for a sync password).
- // TODO: Support WebViews in apps via Digital Asset Links
- // See: https://developer.android.com/reference/android/service/autofill/AutofillService#web-security
- return FormOrigin.App(appPackage)
- }
- return when (trustedBrowserInfo.multiOriginMethod) {
- BrowserMultiOriginMethod.None -> {
- // Security assumption: If a browser is trusted but does not support tracking
- // multiple origins, it is expected to annotate a single field, in most cases its
- // URL bar, with a webOrigin. We err on the side of caution and only trust the
- // reported web origin if no other web origin appears on the page.
- webOriginToFormOrigin(context, webOrigins.singleOrNull() ?: return null)
- }
- BrowserMultiOriginMethod.WebView,
- BrowserMultiOriginMethod.Field -> {
- // Security assumption: For browsers with full autofill support (the `Field` case),
- // every form field is annotated with its origin. For browsers based on WebView,
- // this is true after the web origins of WebViews are passed down to their children.
- //
- // For browsers with the WebView or Field method of multi origin support, we take
- // the single origin among the detected fillable or saveable fields. If this origin
- // is null, but we encountered web origins elsewhere in the AssistStructure, the
- // situation is uncertain and Autofill should not be offered.
- webOriginToFormOrigin(
- context,
- scenario.allFields.map { it.webOrigin }.toSet().singleOrNull() ?: return null
- )
- }
- }
+ return when (trustedBrowserInfo.multiOriginMethod) {
+ BrowserMultiOriginMethod.None -> {
+ // Security assumption: If a browser is trusted but does not support tracking
+ // multiple origins, it is expected to annotate a single field, in most cases its
+ // URL bar, with a webOrigin. We err on the side of caution and only trust the
+ // reported web origin if no other web origin appears on the page.
+ webOriginToFormOrigin(context, webOrigins.singleOrNull() ?: return null)
+ }
+ BrowserMultiOriginMethod.WebView, BrowserMultiOriginMethod.Field -> {
+ // Security assumption: For browsers with full autofill support (the `Field` case),
+ // every form field is annotated with its origin. For browsers based on WebView,
+ // this is true after the web origins of WebViews are passed down to their children.
+ //
+ // For browsers with the WebView or Field method of multi origin support, we take
+ // the single origin among the detected fillable or saveable fields. If this origin
+ // is null, but we encountered web origins elsewhere in the AssistStructure, the
+ // situation is uncertain and Autofill should not be offered.
+ webOriginToFormOrigin(context, scenario.allFields.map { it.webOrigin }.toSet().singleOrNull() ?: return null)
+ }
}
+ }
}
public data class Credentials(val username: String?, val password: String?, val otp: String?)
@@ -191,29 +187,26 @@ public data class Credentials(val username: String?, val password: String?, val
* entry point to all fill and save features.
*/
@RequiresApi(Build.VERSION_CODES.O)
-public class FillableForm private constructor(
- public val formOrigin: FormOrigin,
- public val scenario: AutofillScenario<AutofillId>,
- public val ignoredIds: List<AutofillId>,
- public val saveFlags: Int?
+public class FillableForm
+private constructor(
+ public val formOrigin: FormOrigin,
+ public val scenario: AutofillScenario<AutofillId>,
+ public val ignoredIds: List<AutofillId>,
+ public val saveFlags: Int?
) {
- public companion object {
- /**
- * Returns a [FillableForm] if a login form could be detected in [structure].
- */
- public fun parseAssistStructure(
- context: Context,
- structure: AssistStructure,
- isManualRequest: Boolean,
- customSuffixes: Sequence<String> = emptySequence(),
- ): FillableForm? {
- val form = AutofillFormParser(context, structure, isManualRequest, customSuffixes)
- if (form.formOrigin == null || form.scenario == null) return null
- return FillableForm(form.formOrigin, form.scenario.map { it.autofillId }, form.ignoredIds, form.saveFlags)
- }
+ public companion object {
+ /** Returns a [FillableForm] if a login form could be detected in [structure]. */
+ public fun parseAssistStructure(
+ context: Context,
+ structure: AssistStructure,
+ isManualRequest: Boolean,
+ customSuffixes: Sequence<String> = emptySequence(),
+ ): FillableForm? {
+ val form = AutofillFormParser(context, structure, isManualRequest, customSuffixes)
+ if (form.formOrigin == null || form.scenario == null) return null
+ return FillableForm(form.formOrigin, form.scenario.map { it.autofillId }, form.ignoredIds, form.saveFlags)
}
+ }
- public fun toClientState(): Bundle = scenario.toBundle().apply {
- putAll(formOrigin.toBundle())
- }
+ public fun toClientState(): Bundle = scenario.toBundle().apply { putAll(formOrigin.toBundle()) }
}
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt
index c60a00ec..7f193cfc 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillHelper.kt
@@ -20,19 +20,19 @@ import com.github.ajalt.timberkt.e
import java.security.MessageDigest
private fun ByteArray.sha256(): ByteArray {
- return MessageDigest.getInstance("SHA-256").run {
- update(this@sha256)
- digest()
- }
+ return MessageDigest.getInstance("SHA-256").run {
+ update(this@sha256)
+ digest()
+ }
}
private fun ByteArray.base64(): String {
- return Base64.encodeToString(this, Base64.NO_WRAP)
+ return Base64.encodeToString(this, Base64.NO_WRAP)
}
private fun stableHash(array: Collection<ByteArray>): String {
- val hashes = array.map { it.sha256().base64() }
- return hashes.sorted().joinToString(separator = ";")
+ val hashes = array.map { it.sha256().base64() }
+ return hashes.sorted().joinToString(separator = ";")
}
/**
@@ -43,25 +43,22 @@ private fun stableHash(array: Collection<ByteArray>): String {
* returns all of them in sorted order and separated with `;`.
*/
public fun computeCertificatesHash(context: Context, appPackage: String): String {
- // The warning does not apply since 1) we are specifically hashing **all** signatures and 2) it
- // no longer applies to Android 4.4+.
- // Even though there is a new way to get the certificates as of Android Pie, we need to keep
- // hashes comparable between versions and hence default to using the deprecated API.
- @SuppressLint("PackageManagerGetSignatures")
- @Suppress("DEPRECATION")
- val signaturesOld =
- context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNATURES).signatures
- val stableHashOld = stableHash(signaturesOld.map { it.toByteArray() })
- if (Build.VERSION.SDK_INT >= 28) {
- val info = context.packageManager.getPackageInfo(
- appPackage, PackageManager.GET_SIGNING_CERTIFICATES
- )
- val signaturesNew =
- info.signingInfo.signingCertificateHistory ?: info.signingInfo.apkContentsSigners
- val stableHashNew = stableHash(signaturesNew.map { it.toByteArray() })
- if (stableHashNew != stableHashOld) tag("CertificatesHash").e { "Mismatch between old and new hash: $stableHashNew != $stableHashOld" }
- }
- return stableHashOld
+ // The warning does not apply since 1) we are specifically hashing **all** signatures and 2) it
+ // no longer applies to Android 4.4+.
+ // Even though there is a new way to get the certificates as of Android Pie, we need to keep
+ // hashes comparable between versions and hence default to using the deprecated API.
+ @SuppressLint("PackageManagerGetSignatures")
+ @Suppress("DEPRECATION")
+ val signaturesOld = context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNATURES).signatures
+ val stableHashOld = stableHash(signaturesOld.map { it.toByteArray() })
+ if (Build.VERSION.SDK_INT >= 28) {
+ val info = context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNING_CERTIFICATES)
+ val signaturesNew = info.signingInfo.signingCertificateHistory ?: info.signingInfo.apkContentsSigners
+ val stableHashNew = stableHash(signaturesNew.map { it.toByteArray() })
+ if (stableHashNew != stableHashOld)
+ tag("CertificatesHash").e { "Mismatch between old and new hash: $stableHashNew != $stableHashOld" }
+ }
+ return stableHashOld
}
/**
@@ -69,59 +66,56 @@ public fun computeCertificatesHash(context: Context, appPackage: String): String
* its `webDomain` and `webScheme`, if available.
*/
internal val AssistStructure.ViewNode.webOrigin: String?
- @RequiresApi(Build.VERSION_CODES.O) get() = webDomain?.let { domain ->
- val scheme = (if (Build.VERSION.SDK_INT >= 28) webScheme else null) ?: "https"
- "$scheme://$domain"
+ @RequiresApi(Build.VERSION_CODES.O)
+ get() =
+ webDomain?.let { domain ->
+ val scheme = (if (Build.VERSION.SDK_INT >= 28) webScheme else null) ?: "https"
+ "$scheme://$domain"
}
@RequiresApi(Build.VERSION_CODES.O)
public class FixedSaveCallback(context: Context, private val callback: SaveCallback) {
- private val applicationContext = context.applicationContext
+ private val applicationContext = context.applicationContext
- public fun onFailure(message: CharSequence) {
- callback.onFailure(message)
- // When targeting SDK 29, the message is no longer shown as a toast.
- // See https://developer.android.com/reference/android/service/autofill/SaveCallback#onFailure(java.lang.CharSequence)
- if (applicationContext.applicationInfo.targetSdkVersion >= 29) {
- Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
- }
+ public fun onFailure(message: CharSequence) {
+ callback.onFailure(message)
+ // When targeting SDK 29, the message is no longer shown as a toast.
+ // See
+ // https://developer.android.com/reference/android/service/autofill/SaveCallback#onFailure(java.lang.CharSequence)
+ if (applicationContext.applicationInfo.targetSdkVersion >= 29) {
+ Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
}
+ }
- public fun onSuccess(intentSender: IntentSender) {
- if (Build.VERSION.SDK_INT >= 28) {
- callback.onSuccess(intentSender)
- } else {
- callback.onSuccess()
- // On SDKs < 28, we cannot advise the Autofill framework to launch the save intent in
- // the context of the app that triggered the save request. Hence, we launch it here.
- applicationContext.startIntentSender(intentSender, null, 0, 0, 0)
- }
+ public fun onSuccess(intentSender: IntentSender) {
+ if (Build.VERSION.SDK_INT >= 28) {
+ callback.onSuccess(intentSender)
+ } else {
+ callback.onSuccess()
+ // On SDKs < 28, we cannot advise the Autofill framework to launch the save intent in
+ // the context of the app that triggered the save request. Hence, we launch it here.
+ applicationContext.startIntentSender(intentSender, null, 0, 0, 0)
}
+ }
}
private fun visitViewNodes(structure: AssistStructure, block: (AssistStructure.ViewNode) -> Unit) {
- for (i in 0 until structure.windowNodeCount) {
- visitViewNode(structure.getWindowNodeAt(i).rootViewNode, block)
- }
+ for (i in 0 until structure.windowNodeCount) {
+ visitViewNode(structure.getWindowNodeAt(i).rootViewNode, block)
+ }
}
-private fun visitViewNode(
- node: AssistStructure.ViewNode,
- block: (AssistStructure.ViewNode) -> Unit
-) {
- block(node)
- for (i in 0 until node.childCount) {
- visitViewNode(node.getChildAt(i), block)
- }
+private fun visitViewNode(node: AssistStructure.ViewNode, block: (AssistStructure.ViewNode) -> Unit) {
+ block(node)
+ for (i in 0 until node.childCount) {
+ visitViewNode(node.getChildAt(i), block)
+ }
}
@RequiresApi(Build.VERSION_CODES.O)
internal fun AssistStructure.findNodeByAutofillId(autofillId: AutofillId): AssistStructure.ViewNode? {
- var node: AssistStructure.ViewNode? = null
- visitViewNodes(this) {
- if (it.autofillId == autofillId)
- node = it
- }
- return node
+ var node: AssistStructure.ViewNode? = null
+ visitViewNodes(this) { if (it.autofillId == autofillId) node = it }
+ return node
}
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt
index ad26a36c..7df5d9c5 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillScenario.kt
@@ -14,7 +14,10 @@ import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.e
public enum class AutofillAction {
- Match, Search, Generate, FillOtpFromSms
+ Match,
+ Search,
+ Generate,
+ FillOtpFromSms
}
/**
@@ -26,276 +29,270 @@ public enum class AutofillAction {
@RequiresApi(Build.VERSION_CODES.O)
public sealed class AutofillScenario<out T : Any> {
- public companion object {
+ public companion object {
- internal const val BUNDLE_KEY_USERNAME_ID = "usernameId"
- internal const val BUNDLE_KEY_FILL_USERNAME = "fillUsername"
- internal const val BUNDLE_KEY_OTP_ID = "otpId"
- internal const val BUNDLE_KEY_CURRENT_PASSWORD_IDS = "currentPasswordIds"
- internal const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds"
- internal const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds"
+ internal const val BUNDLE_KEY_USERNAME_ID = "usernameId"
+ internal const val BUNDLE_KEY_FILL_USERNAME = "fillUsername"
+ internal const val BUNDLE_KEY_OTP_ID = "otpId"
+ internal const val BUNDLE_KEY_CURRENT_PASSWORD_IDS = "currentPasswordIds"
+ internal const val BUNDLE_KEY_NEW_PASSWORD_IDS = "newPasswordIds"
+ internal const val BUNDLE_KEY_GENERIC_PASSWORD_IDS = "genericPasswordIds"
- @Deprecated("Use `fromClientState` instead.", ReplaceWith("fromClientState(clientState)", "com.github.androidpasswordstore.autofillparser.AutofillScenario.Companion.fromClientState"))
- public fun fromBundle(clientState: Bundle): AutofillScenario<AutofillId>? {
- return fromClientState(clientState)
- }
+ @Deprecated(
+ "Use `fromClientState` instead.",
+ ReplaceWith(
+ "fromClientState(clientState)",
+ "com.github.androidpasswordstore.autofillparser.AutofillScenario.Companion.fromClientState"
+ )
+ )
+ public fun fromBundle(clientState: Bundle): AutofillScenario<AutofillId>? {
+ return fromClientState(clientState)
+ }
- public fun fromClientState(clientState: Bundle): AutofillScenario<AutofillId>? {
- return try {
- Builder<AutofillId>().apply {
- username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID)
- fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME)
- otp = clientState.getParcelable(BUNDLE_KEY_OTP_ID)
- currentPassword.addAll(
- clientState.getParcelableArrayList(
- BUNDLE_KEY_CURRENT_PASSWORD_IDS
- ) ?: emptyList()
- )
- newPassword.addAll(
- clientState.getParcelableArrayList(
- BUNDLE_KEY_NEW_PASSWORD_IDS
- ) ?: emptyList()
- )
- genericPassword.addAll(
- clientState.getParcelableArrayList(
- BUNDLE_KEY_GENERIC_PASSWORD_IDS
- ) ?: emptyList()
- )
- }.build()
- } catch(e: Throwable) {
- e(e)
- null
- }
- }
+ public fun fromClientState(clientState: Bundle): AutofillScenario<AutofillId>? {
+ return try {
+ Builder<AutofillId>()
+ .apply {
+ username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID)
+ fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME)
+ otp = clientState.getParcelable(BUNDLE_KEY_OTP_ID)
+ currentPassword.addAll(clientState.getParcelableArrayList(BUNDLE_KEY_CURRENT_PASSWORD_IDS) ?: emptyList())
+ newPassword.addAll(clientState.getParcelableArrayList(BUNDLE_KEY_NEW_PASSWORD_IDS) ?: emptyList())
+ genericPassword.addAll(clientState.getParcelableArrayList(BUNDLE_KEY_GENERIC_PASSWORD_IDS) ?: emptyList())
+ }
+ .build()
+ } catch (e: Throwable) {
+ e(e)
+ null
+ }
}
+ }
- internal class Builder<T : Any> {
+ internal class Builder<T : Any> {
- var username: T? = null
- var fillUsername = false
- var otp: T? = null
- val currentPassword = mutableListOf<T>()
- val newPassword = mutableListOf<T>()
- val genericPassword = mutableListOf<T>()
+ var username: T? = null
+ var fillUsername = false
+ var otp: T? = null
+ val currentPassword = mutableListOf<T>()
+ val newPassword = mutableListOf<T>()
+ val genericPassword = mutableListOf<T>()
- fun build(): AutofillScenario<T> {
- require(genericPassword.isEmpty() || (currentPassword.isEmpty() && newPassword.isEmpty()))
- return if (currentPassword.isNotEmpty() || newPassword.isNotEmpty()) {
- ClassifiedAutofillScenario(
- username = username,
- fillUsername = fillUsername,
- otp = otp,
- currentPassword = currentPassword,
- newPassword = newPassword
- )
- } else {
- GenericAutofillScenario(
- username = username,
- fillUsername = fillUsername,
- otp = otp,
- genericPassword = genericPassword
- )
- }
- }
+ fun build(): AutofillScenario<T> {
+ require(genericPassword.isEmpty() || (currentPassword.isEmpty() && newPassword.isEmpty()))
+ return if (currentPassword.isNotEmpty() || newPassword.isNotEmpty()) {
+ ClassifiedAutofillScenario(
+ username = username,
+ fillUsername = fillUsername,
+ otp = otp,
+ currentPassword = currentPassword,
+ newPassword = newPassword
+ )
+ } else {
+ GenericAutofillScenario(
+ username = username,
+ fillUsername = fillUsername,
+ otp = otp,
+ genericPassword = genericPassword
+ )
+ }
}
+ }
- public abstract val username: T?
- public abstract val passwordFieldsToSave: List<T>
+ public abstract val username: T?
+ public abstract val passwordFieldsToSave: List<T>
- internal abstract val otp: T?
- internal abstract val allPasswordFields: List<T>
- internal abstract val fillUsername: Boolean
- internal abstract val passwordFieldsToFillOnMatch: List<T>
- internal abstract val passwordFieldsToFillOnSearch: List<T>
- internal abstract val passwordFieldsToFillOnGenerate: List<T>
+ internal abstract val otp: T?
+ internal abstract val allPasswordFields: List<T>
+ internal abstract val fillUsername: Boolean
+ internal abstract val passwordFieldsToFillOnMatch: List<T>
+ internal abstract val passwordFieldsToFillOnSearch: List<T>
+ internal abstract val passwordFieldsToFillOnGenerate: List<T>
- public val fieldsToSave: List<T>
- get() = listOfNotNull(username) + passwordFieldsToSave
+ public val fieldsToSave: List<T>
+ get() = listOfNotNull(username) + passwordFieldsToSave
- internal val allFields: List<T>
- get() = listOfNotNull(username, otp) + allPasswordFields
+ internal val allFields: List<T>
+ get() = listOfNotNull(username, otp) + allPasswordFields
- internal fun fieldsToFillOn(action: AutofillAction): List<T> {
- val credentialFieldsToFill = when (action) {
- AutofillAction.Match -> passwordFieldsToFillOnMatch + listOfNotNull(otp)
- AutofillAction.Search -> passwordFieldsToFillOnSearch + listOfNotNull(otp)
- AutofillAction.Generate -> passwordFieldsToFillOnGenerate
- AutofillAction.FillOtpFromSms -> listOfNotNull(otp)
- }
- return when {
- action == AutofillAction.FillOtpFromSms -> {
- // When filling from an SMS, we cannot get any data other than the OTP itself.
- credentialFieldsToFill
- }
- credentialFieldsToFill.isNotEmpty() -> {
- // If the current action would fill into any password field, we also fill into the
- // username field if possible.
- listOfNotNull(username.takeIf { fillUsername }) + credentialFieldsToFill
- }
- allPasswordFields.isEmpty() && action != AutofillAction.Generate -> {
- // If there no password fields at all, we still offer to fill the username, e.g. in
- // two-step login scenarios, but we do not offer to generate a password.
- listOfNotNull(username.takeIf { fillUsername })
- }
- else -> emptyList()
- }
+ internal fun fieldsToFillOn(action: AutofillAction): List<T> {
+ val credentialFieldsToFill =
+ when (action) {
+ AutofillAction.Match -> passwordFieldsToFillOnMatch + listOfNotNull(otp)
+ AutofillAction.Search -> passwordFieldsToFillOnSearch + listOfNotNull(otp)
+ AutofillAction.Generate -> passwordFieldsToFillOnGenerate
+ AutofillAction.FillOtpFromSms -> listOfNotNull(otp)
+ }
+ return when {
+ action == AutofillAction.FillOtpFromSms -> {
+ // When filling from an SMS, we cannot get any data other than the OTP itself.
+ credentialFieldsToFill
+ }
+ credentialFieldsToFill.isNotEmpty() -> {
+ // If the current action would fill into any password field, we also fill into the
+ // username field if possible.
+ listOfNotNull(username.takeIf { fillUsername }) + credentialFieldsToFill
+ }
+ allPasswordFields.isEmpty() && action != AutofillAction.Generate -> {
+ // If there no password fields at all, we still offer to fill the username, e.g. in
+ // two-step login scenarios, but we do not offer to generate a password.
+ listOfNotNull(username.takeIf { fillUsername })
+ }
+ else -> emptyList()
}
+ }
- public fun hasFieldsToFillOn(action: AutofillAction): Boolean {
- return fieldsToFillOn(action).isNotEmpty()
- }
+ public fun hasFieldsToFillOn(action: AutofillAction): Boolean {
+ return fieldsToFillOn(action).isNotEmpty()
+ }
- public val hasFieldsToSave: Boolean
- get() = fieldsToSave.isNotEmpty()
+ public val hasFieldsToSave: Boolean
+ get() = fieldsToSave.isNotEmpty()
- public val hasPasswordFieldsToSave: Boolean
- get() = fieldsToSave.minus(listOfNotNull(username)).isNotEmpty()
+ public val hasPasswordFieldsToSave: Boolean
+ get() = fieldsToSave.minus(listOfNotNull(username)).isNotEmpty()
- public val hasUsername: Boolean
- get() = username != null
+ public val hasUsername: Boolean
+ get() = username != null
}
@RequiresApi(Build.VERSION_CODES.O)
internal data class ClassifiedAutofillScenario<T : Any>(
- override val username: T?,
- override val fillUsername: Boolean,
- override val otp: T?,
- val currentPassword: List<T>,
- val newPassword: List<T>
+ override val username: T?,
+ override val fillUsername: Boolean,
+ override val otp: T?,
+ val currentPassword: List<T>,
+ val newPassword: List<T>
) : AutofillScenario<T>() {
- override val allPasswordFields
- get() = currentPassword + newPassword
- override val passwordFieldsToFillOnMatch
- get() = currentPassword
- override val passwordFieldsToFillOnSearch
- get() = currentPassword
- override val passwordFieldsToFillOnGenerate
- get() = newPassword
- override val passwordFieldsToSave
- get() = if (newPassword.isNotEmpty()) newPassword else currentPassword
+ override val allPasswordFields
+ get() = currentPassword + newPassword
+ override val passwordFieldsToFillOnMatch
+ get() = currentPassword
+ override val passwordFieldsToFillOnSearch
+ get() = currentPassword
+ override val passwordFieldsToFillOnGenerate
+ get() = newPassword
+ override val passwordFieldsToSave
+ get() = if (newPassword.isNotEmpty()) newPassword else currentPassword
}
@RequiresApi(Build.VERSION_CODES.O)
internal data class GenericAutofillScenario<T : Any>(
- override val username: T?,
- override val fillUsername: Boolean,
- override val otp: T?,
- val genericPassword: List<T>
+ override val username: T?,
+ override val fillUsername: Boolean,
+ override val otp: T?,
+ val genericPassword: List<T>
) : AutofillScenario<T>() {
- override val allPasswordFields
- get() = genericPassword
- override val passwordFieldsToFillOnMatch
- get() = if (genericPassword.size == 1) genericPassword else emptyList()
- override val passwordFieldsToFillOnSearch
- get() = if (genericPassword.size == 1) genericPassword else emptyList()
- override val passwordFieldsToFillOnGenerate
- get() = genericPassword
- override val passwordFieldsToSave
- get() = genericPassword
+ override val allPasswordFields
+ get() = genericPassword
+ override val passwordFieldsToFillOnMatch
+ get() = if (genericPassword.size == 1) genericPassword else emptyList()
+ override val passwordFieldsToFillOnSearch
+ get() = if (genericPassword.size == 1) genericPassword else emptyList()
+ override val passwordFieldsToFillOnGenerate
+ get() = genericPassword
+ override val passwordFieldsToSave
+ get() = genericPassword
}
internal fun AutofillScenario<FormField>.passesOriginCheck(singleOriginMode: Boolean): Boolean {
- return if (singleOriginMode) {
- // In single origin mode, only the browsers URL bar (which is never filled) should have
- // a webOrigin.
- allFields.all { it.webOrigin == null }
- } else {
- // In apps or browsers in multi origin mode, every field in a dataset has to belong to
- // the same (possibly null) origin.
- allFields.map { it.webOrigin }.toSet().size == 1
- }
+ return if (singleOriginMode) {
+ // In single origin mode, only the browsers URL bar (which is never filled) should have
+ // a webOrigin.
+ allFields.all { it.webOrigin == null }
+ } else {
+ // In apps or browsers in multi origin mode, every field in a dataset has to belong to
+ // the same (possibly null) origin.
+ allFields.map { it.webOrigin }.toSet().size == 1
+ }
}
@RequiresApi(Build.VERSION_CODES.O)
@JvmName("fillWithAutofillId")
public fun Dataset.Builder.fillWith(
- scenario: AutofillScenario<AutofillId>,
- action: AutofillAction,
- credentials: Credentials?
+ scenario: AutofillScenario<AutofillId>,
+ action: AutofillAction,
+ credentials: Credentials?
) {
- val credentialsToFill = credentials ?: Credentials(
- "USERNAME",
- "PASSWORD",
- "OTP"
- )
- for (field in scenario.fieldsToFillOn(action)) {
- val value = when (field) {
- scenario.username -> credentialsToFill.username
- scenario.otp -> credentialsToFill.otp
- else -> credentialsToFill.password
- }
- setValue(field, AutofillValue.forText(value))
- }
+ val credentialsToFill = credentials ?: Credentials("USERNAME", "PASSWORD", "OTP")
+ for (field in scenario.fieldsToFillOn(action)) {
+ val value =
+ when (field) {
+ scenario.username -> credentialsToFill.username
+ scenario.otp -> credentialsToFill.otp
+ else -> credentialsToFill.password
+ }
+ setValue(field, AutofillValue.forText(value))
+ }
}
internal inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): AutofillScenario<S> {
- val builder = AutofillScenario.Builder<S>()
- builder.username = username?.let(transform)
- builder.fillUsername = fillUsername
- builder.otp = otp?.let(transform)
- when (this) {
- is ClassifiedAutofillScenario -> {
- builder.currentPassword.addAll(currentPassword.map(transform))
- builder.newPassword.addAll(newPassword.map(transform))
- }
- is GenericAutofillScenario -> {
- builder.genericPassword.addAll(genericPassword.map(transform))
- }
+ val builder = AutofillScenario.Builder<S>()
+ builder.username = username?.let(transform)
+ builder.fillUsername = fillUsername
+ builder.otp = otp?.let(transform)
+ when (this) {
+ is ClassifiedAutofillScenario -> {
+ builder.currentPassword.addAll(currentPassword.map(transform))
+ builder.newPassword.addAll(newPassword.map(transform))
}
- return builder.build()
+ is GenericAutofillScenario -> {
+ builder.genericPassword.addAll(genericPassword.map(transform))
+ }
+ }
+ return builder.build()
}
@RequiresApi(Build.VERSION_CODES.O)
@JvmName("toBundleAutofillId")
-internal fun AutofillScenario<AutofillId>.toBundle(): Bundle = when (this) {
+internal fun AutofillScenario<AutofillId>.toBundle(): Bundle =
+ when (this) {
is ClassifiedAutofillScenario<AutofillId> -> {
- Bundle(5).apply {
- putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
- putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
- putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
- putParcelableArrayList(
- AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword)
- )
- putParcelableArrayList(
- AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword)
- )
- }
+ Bundle(5).apply {
+ putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
+ putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
+ putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
+ putParcelableArrayList(AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword))
+ putParcelableArrayList(AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword))
+ }
}
is GenericAutofillScenario<AutofillId> -> {
- Bundle(4).apply {
- putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
- putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
- putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
- putParcelableArrayList(
- AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS, ArrayList(genericPassword)
- )
- }
+ Bundle(4).apply {
+ putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
+ putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
+ putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
+ putParcelableArrayList(AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS, ArrayList(genericPassword))
+ }
}
-}
+ }
@RequiresApi(Build.VERSION_CODES.O)
-public fun AutofillScenario<AutofillId>.recoverNodes(structure: AssistStructure): AutofillScenario<AssistStructure.ViewNode>? {
- return map { autofillId ->
- structure.findNodeByAutofillId(autofillId) ?: return null
- }
+public fun AutofillScenario<AutofillId>.recoverNodes(
+ structure: AssistStructure
+): AutofillScenario<AssistStructure.ViewNode>? {
+ return map { autofillId -> structure.findNodeByAutofillId(autofillId) ?: return null }
}
public val AutofillScenario<AssistStructure.ViewNode>.usernameValue: String?
- @RequiresApi(Build.VERSION_CODES.O) get() {
- val value = username?.autofillValue ?: return null
- return if (value.isText) value.textValue.toString() else null
- }
+ @RequiresApi(Build.VERSION_CODES.O)
+ get() {
+ val value = username?.autofillValue ?: return null
+ return if (value.isText) value.textValue.toString() else null
+ }
public val AutofillScenario<AssistStructure.ViewNode>.passwordValue: String?
- @RequiresApi(Build.VERSION_CODES.O) get() {
- val distinctValues = passwordFieldsToSave.map {
- if (it.autofillValue?.isText == true) {
- it.autofillValue?.textValue?.toString()
- } else {
- null
- }
- }.toSet()
- // Only return a non-null password value when all password fields agree
- return distinctValues.singleOrNull()
- }
+ @RequiresApi(Build.VERSION_CODES.O)
+ get() {
+ val distinctValues =
+ passwordFieldsToSave
+ .map {
+ if (it.autofillValue?.isText == true) {
+ it.autofillValue?.textValue?.toString()
+ } else {
+ null
+ }
+ }
+ .toSet()
+ // Only return a non-null password value when all password fields agree
+ return distinctValues.singleOrNull()
+ }
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt
index 94cc17ba..c1e0f234 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategy.kt
@@ -9,220 +9,172 @@ import androidx.annotation.RequiresApi
import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Certain
import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Likely
-private inline fun <T> Pair<T, T>.all(predicate: T.() -> Boolean) =
- predicate(first) && predicate(second)
+private inline fun <T> Pair<T, T>.all(predicate: T.() -> Boolean) = predicate(first) && predicate(second)
-private inline fun <T> Pair<T, T>.any(predicate: T.() -> Boolean) =
- predicate(first) || predicate(second)
+private inline fun <T> Pair<T, T>.any(predicate: T.() -> Boolean) = predicate(first) || predicate(second)
-private inline fun <T> Pair<T, T>.none(predicate: T.() -> Boolean) =
- !predicate(first) && !predicate(second)
+private inline fun <T> Pair<T, T>.none(predicate: T.() -> Boolean) = !predicate(first) && !predicate(second)
/**
- * The strategy used to detect [AutofillScenario]s; expressed using the DSL implemented in
+ * The strategy used to detect [AutofillScenario] s; expressed using the DSL implemented in
* [AutofillDsl].
*/
@RequiresApi(Build.VERSION_CODES.O)
internal val autofillStrategy = strategy {
- // Match two new password fields, an optional current password field right below or above, and
- // an optional username field with autocomplete hint.
- // TODO: Introduce a custom fill/generate/update flow for this scenario
- rule {
- newPassword {
- takePair { all { hasHintNewPassword } }
- breakTieOnPair { any { isFocused } }
- }
- currentPassword(optional = true) {
- takeSingle { alreadyMatched ->
- val adjacentToNewPasswords =
- directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched)
- // The Autofill framework has not hint that applies to current passwords only.
- // In this scenario, we have already matched fields a pair of fields with a specific
- // new password hint, so we take a generic Autofill password hint to mean a current
- // password.
- (hasAutocompleteHintCurrentPassword || hasAutofillHintPassword) &&
- adjacentToNewPasswords
- }
- }
- username(optional = true) {
- takeSingle { hasHintUsername }
- breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
- breakTieOnSingle { isFocused }
- }
+ // Match two new password fields, an optional current password field right below or above, and
+ // an optional username field with autocomplete hint.
+ // TODO: Introduce a custom fill/generate/update flow for this scenario
+ rule {
+ newPassword {
+ takePair { all { hasHintNewPassword } }
+ breakTieOnPair { any { isFocused } }
}
-
- // Match a single focused current password field and hidden username field with autocomplete
- // hint. This configuration is commonly used in two-step login flows to allow password managers
- // to save the username.
- // See: https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
- // Note: The username is never filled in this scenario since usernames are generally only filled
- // in visible fields.
- rule {
- username(matchHidden = true) {
- takeSingle {
- couldBeTwoStepHiddenUsername
- }
- }
- currentPassword {
- takeSingle {
- hasAutocompleteHintCurrentPassword && isFocused
- }
- }
+ currentPassword(optional = true) {
+ takeSingle { alreadyMatched ->
+ val adjacentToNewPasswords = directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched)
+ // The Autofill framework has not hint that applies to current passwords only.
+ // In this scenario, we have already matched fields a pair of fields with a specific
+ // new password hint, so we take a generic Autofill password hint to mean a current
+ // password.
+ (hasAutocompleteHintCurrentPassword || hasAutofillHintPassword) && adjacentToNewPasswords
+ }
}
-
- // Match a single current password field and optional username field with autocomplete hint.
- rule {
- currentPassword {
- takeSingle { hasAutocompleteHintCurrentPassword }
- breakTieOnSingle { isFocused }
- }
- username(optional = true) {
- takeSingle { hasHintUsername }
- breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
- breakTieOnSingle { isFocused }
- }
+ username(optional = true) {
+ takeSingle { hasHintUsername }
+ breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
+ breakTieOnSingle { isFocused }
}
-
- // Match two adjacent password fields, implicitly understood as new passwords, and optional
- // username field.
- rule {
- newPassword {
- takePair { all { passwordCertainty >= Likely } }
- breakTieOnPair { all { passwordCertainty >= Certain } }
- breakTieOnPair { any { isFocused } }
- }
- username(optional = true) {
- takeSingle { usernameCertainty >= Likely }
- breakTieOnSingle { usernameCertainty >= Certain }
- breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
- breakTieOnSingle { isFocused }
- }
+ }
+
+ // Match a single focused current password field and hidden username field with autocomplete
+ // hint. This configuration is commonly used in two-step login flows to allow password managers
+ // to save the username.
+ // See:
+ // https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
+ // Note: The username is never filled in this scenario since usernames are generally only filled
+ // in visible fields.
+ rule {
+ username(matchHidden = true) { takeSingle { couldBeTwoStepHiddenUsername } }
+ currentPassword { takeSingle { hasAutocompleteHintCurrentPassword && isFocused } }
+ }
+
+ // Match a single current password field and optional username field with autocomplete hint.
+ rule {
+ currentPassword {
+ takeSingle { hasAutocompleteHintCurrentPassword }
+ breakTieOnSingle { isFocused }
}
-
- // Match a single password field and optional username field.
- rule {
- genericPassword {
- takeSingle { passwordCertainty >= Likely }
- breakTieOnSingle { passwordCertainty >= Certain }
- breakTieOnSingle { isFocused }
- }
- username(optional = true) {
- takeSingle { usernameCertainty >= Likely }
- breakTieOnSingle { usernameCertainty >= Certain }
- breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
- breakTieOnSingle { isFocused }
- }
+ username(optional = true) {
+ takeSingle { hasHintUsername }
+ breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
+ breakTieOnSingle { isFocused }
}
-
- // Match a single focused new password field and optional preceding username field.
- // This rule can apply in single origin mode since it only fills into a single focused password
- // field.
- rule(applyInSingleOriginMode = true) {
- newPassword {
- takeSingle { hasHintNewPassword && isFocused }
- }
- username(optional = true) {
- takeSingle { alreadyMatched ->
- usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
- }
- }
+ }
+
+ // Match two adjacent password fields, implicitly understood as new passwords, and optional
+ // username field.
+ rule {
+ newPassword {
+ takePair { all { passwordCertainty >= Likely } }
+ breakTieOnPair { all { passwordCertainty >= Certain } }
+ breakTieOnPair { any { isFocused } }
}
-
- // Match a single focused current password field and optional preceding username field.
- // This rule can apply in single origin mode since it only fills into a single focused password
- // field.
- rule(applyInSingleOriginMode = true) {
- currentPassword {
- takeSingle { hasAutocompleteHintCurrentPassword && isFocused }
- }
- username(optional = true) {
- takeSingle { alreadyMatched ->
- usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
- }
- }
+ username(optional = true) {
+ takeSingle { usernameCertainty >= Likely }
+ breakTieOnSingle { usernameCertainty >= Certain }
+ breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
+ breakTieOnSingle { isFocused }
}
-
- // Match a single focused password field and optional preceding username field.
- // This rule can apply in single origin mode since it only fills into a single focused password
- // field.
- rule(applyInSingleOriginMode = true) {
- genericPassword {
- takeSingle { passwordCertainty >= Likely && isFocused }
- }
- username(optional = true) {
- takeSingle { alreadyMatched ->
- usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
- }
- }
+ }
+
+ // Match a single password field and optional username field.
+ rule {
+ genericPassword {
+ takeSingle { passwordCertainty >= Likely }
+ breakTieOnSingle { passwordCertainty >= Certain }
+ breakTieOnSingle { isFocused }
}
-
- // Match a focused username field with autocomplete hint directly followed by a hidden password
- // field, which is a common scenario in two-step login flows. No tie breakers are used to limit
- // filling of hidden password fields to scenarios where this is clearly warranted.
- rule {
- username {
- takeSingle { hasHintUsername && isFocused }
- }
- currentPassword(matchHidden = true) {
- takeSingle { alreadyMatched ->
- directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword
- }
- }
+ username(optional = true) {
+ takeSingle { usernameCertainty >= Likely }
+ breakTieOnSingle { usernameCertainty >= Certain }
+ breakTieOnSingle { alreadyMatched -> directlyPrecedes(alreadyMatched) }
+ breakTieOnSingle { isFocused }
}
-
- // Match a single focused OTP field.
- rule(applyInSingleOriginMode = true) {
- otp {
- takeSingle { otpCertainty >= Likely && isFocused }
- }
+ }
+
+ // Match a single focused new password field and optional preceding username field.
+ // This rule can apply in single origin mode since it only fills into a single focused password
+ // field.
+ rule(applyInSingleOriginMode = true) {
+ newPassword { takeSingle { hasHintNewPassword && isFocused } }
+ username(optional = true) {
+ takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) }
}
-
- // Match a single focused username field without a password field.
- rule(applyInSingleOriginMode = true) {
- username {
- takeSingle { usernameCertainty >= Likely && isFocused }
- breakTieOnSingle { usernameCertainty >= Certain }
- breakTieOnSingle { hasHintUsername }
- }
+ }
+
+ // Match a single focused current password field and optional preceding username field.
+ // This rule can apply in single origin mode since it only fills into a single focused password
+ // field.
+ rule(applyInSingleOriginMode = true) {
+ currentPassword { takeSingle { hasAutocompleteHintCurrentPassword && isFocused } }
+ username(optional = true) {
+ takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) }
}
-
- // Fallback rule for the case of a login form with a password field and other fields that are
- // not recognized by any other rule. If one of the other fields is focused and we return no
- // response, the system will not invoke the service again if focus later changes to the password
- // field. Hence, we must mark it as fillable now.
- // This rule can apply in single origin mode since even though the password field may not be
- // focused at the time the rule runs, the fill suggestion will only show if it ever receives
- // focus.
- rule(applyInSingleOriginMode = true) {
- currentPassword {
- takeSingle { hasAutocompleteHintCurrentPassword }
- }
+ }
+
+ // Match a single focused password field and optional preceding username field.
+ // This rule can apply in single origin mode since it only fills into a single focused password
+ // field.
+ rule(applyInSingleOriginMode = true) {
+ genericPassword { takeSingle { passwordCertainty >= Likely && isFocused } }
+ username(optional = true) {
+ takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) }
}
-
- // See above.
- rule(applyInSingleOriginMode = true) {
- genericPassword {
- takeSingle { true }
- }
+ }
+
+ // Match a focused username field with autocomplete hint directly followed by a hidden password
+ // field, which is a common scenario in two-step login flows. No tie breakers are used to limit
+ // filling of hidden password fields to scenarios where this is clearly warranted.
+ rule {
+ username { takeSingle { hasHintUsername && isFocused } }
+ currentPassword(matchHidden = true) {
+ takeSingle { alreadyMatched -> directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword }
}
+ }
- // Match any focused password field with optional username field on manual request.
- rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) {
- genericPassword {
- takeSingle { isFocused }
- }
- username(optional = true) {
- takeSingle { alreadyMatched ->
- usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
- }
- }
- }
+ // Match a single focused OTP field.
+ rule(applyInSingleOriginMode = true) { otp { takeSingle { otpCertainty >= Likely && isFocused } } }
- // Match any focused username field on manual request.
- rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) {
- username {
- takeSingle { isFocused }
- }
+ // Match a single focused username field without a password field.
+ rule(applyInSingleOriginMode = true) {
+ username {
+ takeSingle { usernameCertainty >= Likely && isFocused }
+ breakTieOnSingle { usernameCertainty >= Certain }
+ breakTieOnSingle { hasHintUsername }
+ }
+ }
+
+ // Fallback rule for the case of a login form with a password field and other fields that are
+ // not recognized by any other rule. If one of the other fields is focused and we return no
+ // response, the system will not invoke the service again if focus later changes to the password
+ // field. Hence, we must mark it as fillable now.
+ // This rule can apply in single origin mode since even though the password field may not be
+ // focused at the time the rule runs, the fill suggestion will only show if it ever receives
+ // focus.
+ rule(applyInSingleOriginMode = true) { currentPassword { takeSingle { hasAutocompleteHintCurrentPassword } } }
+
+ // See above.
+ rule(applyInSingleOriginMode = true) { genericPassword { takeSingle { true } } }
+
+ // Match any focused password field with optional username field on manual request.
+ rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) {
+ genericPassword { takeSingle { isFocused } }
+ username(optional = true) {
+ takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) }
}
+ }
+
+ // Match any focused username field on manual request.
+ rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) { username { takeSingle { isFocused } } }
}
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt
index 3e15fda8..293ec467 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/AutofillStrategyDsl.kt
@@ -9,381 +9,404 @@ import androidx.annotation.RequiresApi
import com.github.ajalt.timberkt.d
import com.github.ajalt.timberkt.w
-@DslMarker
-internal annotation class AutofillDsl
+@DslMarker internal annotation class AutofillDsl
@RequiresApi(Build.VERSION_CODES.O)
internal interface FieldMatcher {
- fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>?
+ fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>?
- @AutofillDsl
- class Builder {
+ @AutofillDsl
+ class Builder {
- private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
- private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> =
- mutableListOf()
+ private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
+ private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> = mutableListOf()
- private var takePair: (Pair<FormField, FormField>.(List<FormField>) -> Boolean)? = null
- private var tieBreakersPair: MutableList<Pair<FormField, FormField>.(List<FormField>) -> Boolean> =
- mutableListOf()
+ private var takePair: (Pair<FormField, FormField>.(List<FormField>) -> Boolean)? = null
+ private var tieBreakersPair: MutableList<Pair<FormField, FormField>.(List<FormField>) -> Boolean> = mutableListOf()
- fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
- check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
- takeSingle = block
- }
+ fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
+ check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
+ takeSingle = block
+ }
- fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
- check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
- check(takePair == null) { "takePair cannot be mixed with breakTieOnSingle" }
- tieBreakersSingle.add(block)
- }
+ fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
+ check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
+ check(takePair == null) { "takePair cannot be mixed with breakTieOnSingle" }
+ tieBreakersSingle.add(block)
+ }
- fun takePair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
- check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
- takePair = block
- }
+ fun takePair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
+ check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
+ takePair = block
+ }
- fun breakTieOnPair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean) {
- check(takePair != null) { "Every block needs a takePair block before a breakTieOnPair block" }
- check(takeSingle == null) { "takeSingle cannot be mixed with breakTieOnPair" }
- tieBreakersPair.add(block)
- }
+ fun breakTieOnPair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean) {
+ check(takePair != null) { "Every block needs a takePair block before a breakTieOnPair block" }
+ check(takeSingle == null) { "takeSingle cannot be mixed with breakTieOnPair" }
+ tieBreakersPair.add(block)
+ }
- fun build(): FieldMatcher {
- val takeSingle = takeSingle
- val takePair = takePair
- return when {
- takeSingle != null -> SingleFieldMatcher(takeSingle, tieBreakersSingle)
- takePair != null -> PairOfFieldsMatcher(takePair, tieBreakersPair)
- else -> throw IllegalArgumentException("Every block needs a take{Single,Pair} block")
- }
- }
+ fun build(): FieldMatcher {
+ val takeSingle = takeSingle
+ val takePair = takePair
+ return when {
+ takeSingle != null -> SingleFieldMatcher(takeSingle, tieBreakersSingle)
+ takePair != null -> PairOfFieldsMatcher(takePair, tieBreakersPair)
+ else -> throw IllegalArgumentException("Every block needs a take{Single,Pair} block")
+ }
}
+ }
}
@RequiresApi(Build.VERSION_CODES.O)
internal class SingleFieldMatcher(
- private val take: (FormField, List<FormField>) -> Boolean,
- private val tieBreakers: List<(FormField, List<FormField>) -> Boolean>
+ private val take: (FormField, List<FormField>) -> Boolean,
+ private val tieBreakers: List<(FormField, List<FormField>) -> Boolean>
) : FieldMatcher {
- @AutofillDsl
- class Builder {
+ @AutofillDsl
+ class Builder {
- private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
- private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> =
- mutableListOf()
-
- fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
- check(takeSingle == null) { "Every block can only have at most one takeSingle block" }
- takeSingle = block
- }
+ private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
+ private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> = mutableListOf()
- fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
- check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
- tieBreakersSingle.add(block)
- }
+ fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
+ check(takeSingle == null) { "Every block can only have at most one takeSingle block" }
+ takeSingle = block
+ }
- fun build() = SingleFieldMatcher(
- takeSingle
- ?: throw IllegalArgumentException("Every block needs a take{Single,Pair} block"),
- tieBreakersSingle
- )
+ fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
+ check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
+ tieBreakersSingle.add(block)
}
- override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? {
- return fields.minus(alreadyMatched).filter { take(it, alreadyMatched) }.let { contestants ->
- when (contestants.size) {
- 1 -> return@let listOf(contestants.single())
- 0 -> return@let null
- }
- var current = contestants
- for ((i, tieBreaker) in tieBreakers.withIndex()) {
- // Successively filter matched fields via tie breakers...
- val new = current.filter { tieBreaker(it, alreadyMatched) }
- // skipping those tie breakers that are not satisfied for any remaining field...
- if (new.isEmpty()) {
- d { "Tie breaker #${i + 1}: Didn't match any field; skipping" }
- continue
- }
- // and return if the available options have been narrowed to a single field.
- if (new.size == 1) {
- d { "Tie breaker #${i + 1}: Success" }
- current = new
- break
- }
- d { "Tie breaker #${i + 1}: Matched ${new.size} fields; continuing" }
- current = new
- }
- listOf(current.singleOrNull() ?: return null)
+ fun build() =
+ SingleFieldMatcher(
+ takeSingle ?: throw IllegalArgumentException("Every block needs a take{Single,Pair} block"),
+ tieBreakersSingle
+ )
+ }
+
+ override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? {
+ return fields.minus(alreadyMatched).filter { take(it, alreadyMatched) }.let { contestants ->
+ when (contestants.size) {
+ 1 -> return@let listOf(contestants.single())
+ 0 -> return@let null
+ }
+ var current = contestants
+ for ((i, tieBreaker) in tieBreakers.withIndex()) {
+ // Successively filter matched fields via tie breakers...
+ val new = current.filter { tieBreaker(it, alreadyMatched) }
+ // skipping those tie breakers that are not satisfied for any remaining field...
+ if (new.isEmpty()) {
+ d { "Tie breaker #${i + 1}: Didn't match any field; skipping" }
+ continue
}
+ // and return if the available options have been narrowed to a single field.
+ if (new.size == 1) {
+ d { "Tie breaker #${i + 1}: Success" }
+ current = new
+ break
+ }
+ d { "Tie breaker #${i + 1}: Matched ${new.size} fields; continuing" }
+ current = new
+ }
+ listOf(current.singleOrNull() ?: return null)
}
+ }
}
@RequiresApi(Build.VERSION_CODES.O)
private class PairOfFieldsMatcher(
- private val take: (Pair<FormField, FormField>, List<FormField>) -> Boolean,
- private val tieBreakers: List<(Pair<FormField, FormField>, List<FormField>) -> Boolean>
+ private val take: (Pair<FormField, FormField>, List<FormField>) -> Boolean,
+ private val tieBreakers: List<(Pair<FormField, FormField>, List<FormField>) -> Boolean>
) : FieldMatcher {
- override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? {
- return fields.minus(alreadyMatched).zipWithNext()
- .filter { it.first directlyPrecedes it.second }.filter { take(it, alreadyMatched) }
- .let { contestants ->
- when (contestants.size) {
- 1 -> return@let contestants.single().toList()
- 0 -> return@let null
- }
- var current = contestants
- for ((i, tieBreaker) in tieBreakers.withIndex()) {
- val new = current.filter { tieBreaker(it, alreadyMatched) }
- if (new.isEmpty()) {
- d { "Tie breaker #${i + 1}: Didn't match any pair of fields; skipping" }
- continue
- }
- // and return if the available options have been narrowed to a single field.
- if (new.size == 1) {
- d { "Tie breaker #${i + 1}: Success" }
- current = new
- break
- }
- d { "Tie breaker #${i + 1}: Matched ${new.size} pairs of fields; continuing" }
- current = new
- }
- current.singleOrNull()?.toList()
- }
- }
+ override fun match(fields: List<FormField>, alreadyMatched: List<FormField>): List<FormField>? {
+ return fields
+ .minus(alreadyMatched)
+ .zipWithNext()
+ .filter { it.first directlyPrecedes it.second }
+ .filter { take(it, alreadyMatched) }
+ .let { contestants ->
+ when (contestants.size) {
+ 1 -> return@let contestants.single().toList()
+ 0 -> return@let null
+ }
+ var current = contestants
+ for ((i, tieBreaker) in tieBreakers.withIndex()) {
+ val new = current.filter { tieBreaker(it, alreadyMatched) }
+ if (new.isEmpty()) {
+ d { "Tie breaker #${i + 1}: Didn't match any pair of fields; skipping" }
+ continue
+ }
+ // and return if the available options have been narrowed to a single field.
+ if (new.size == 1) {
+ d { "Tie breaker #${i + 1}: Success" }
+ current = new
+ break
+ }
+ d { "Tie breaker #${i + 1}: Matched ${new.size} pairs of fields; continuing" }
+ current = new
+ }
+ current.singleOrNull()?.toList()
+ }
+ }
}
@RequiresApi(Build.VERSION_CODES.O)
-internal class AutofillRule private constructor(
- private val matchers: List<AutofillRuleMatcher>,
- private val applyInSingleOriginMode: Boolean,
- private val applyOnManualRequestOnly: Boolean,
- private val name: String
+internal class AutofillRule
+private constructor(
+ private val matchers: List<AutofillRuleMatcher>,
+ private val applyInSingleOriginMode: Boolean,
+ private val applyOnManualRequestOnly: Boolean,
+ private val name: String
) {
- data class AutofillRuleMatcher(
- val type: FillableFieldType,
- val matcher: FieldMatcher,
- val optional: Boolean,
- val matchHidden: Boolean
- )
+ data class AutofillRuleMatcher(
+ val type: FillableFieldType,
+ val matcher: FieldMatcher,
+ val optional: Boolean,
+ val matchHidden: Boolean
+ )
+
+ enum class FillableFieldType {
+ Username,
+ Otp,
+ CurrentPassword,
+ NewPassword,
+ GenericPassword,
+ }
+
+ @AutofillDsl
+ class Builder(private val applyInSingleOriginMode: Boolean, private val applyOnManualRequestOnly: Boolean) {
+
+ companion object {
- enum class FillableFieldType {
- Username, Otp, CurrentPassword, NewPassword, GenericPassword,
+ private var ruleId = 1
}
- @AutofillDsl
- class Builder(
- private val applyInSingleOriginMode: Boolean,
- private val applyOnManualRequestOnly: Boolean
- ) {
+ private val matchers = mutableListOf<AutofillRuleMatcher>()
+ var name: String? = null
- companion object {
+ fun username(
+ optional: Boolean = false,
+ matchHidden: Boolean = false,
+ block: SingleFieldMatcher.Builder.() -> Unit
+ ) {
+ require(matchers.none { it.type == FillableFieldType.Username }) {
+ "Every rule block can only have at most one username block"
+ }
+ matchers.add(
+ AutofillRuleMatcher(
+ type = FillableFieldType.Username,
+ matcher = SingleFieldMatcher.Builder().apply(block).build(),
+ optional = optional,
+ matchHidden = matchHidden
+ )
+ )
+ }
- private var ruleId = 1
- }
+ fun otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) {
+ require(matchers.none { it.type == FillableFieldType.Otp }) {
+ "Every rule block can only have at most one otp block"
+ }
+ matchers.add(
+ AutofillRuleMatcher(
+ type = FillableFieldType.Otp,
+ matcher = SingleFieldMatcher.Builder().apply(block).build(),
+ optional = optional,
+ matchHidden = false
+ )
+ )
+ }
- private val matchers = mutableListOf<AutofillRuleMatcher>()
- var name: String? = null
-
- fun username(optional: Boolean = false, matchHidden: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) {
- require(matchers.none { it.type == FillableFieldType.Username }) { "Every rule block can only have at most one username block" }
- matchers.add(
- AutofillRuleMatcher(
- type = FillableFieldType.Username,
- matcher = SingleFieldMatcher.Builder().apply(block).build(),
- optional = optional,
- matchHidden = matchHidden
- )
- )
- }
+ fun currentPassword(
+ optional: Boolean = false,
+ matchHidden: Boolean = false,
+ block: FieldMatcher.Builder.() -> Unit
+ ) {
+ require(matchers.none { it.type == FillableFieldType.GenericPassword }) {
+ "Every rule block can only have either genericPassword or {current,new}Password blocks"
+ }
+ matchers.add(
+ AutofillRuleMatcher(
+ type = FillableFieldType.CurrentPassword,
+ matcher = FieldMatcher.Builder().apply(block).build(),
+ optional = optional,
+ matchHidden = matchHidden
+ )
+ )
+ }
- fun otp(optional: Boolean = false, block: SingleFieldMatcher.Builder.() -> Unit) {
- require(matchers.none { it.type == FillableFieldType.Otp }) { "Every rule block can only have at most one otp block" }
- matchers.add(
- AutofillRuleMatcher(
- type = FillableFieldType.Otp,
- matcher = SingleFieldMatcher.Builder().apply(block).build(),
- optional = optional,
- matchHidden = false
- )
- )
- }
+ fun newPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
+ require(matchers.none { it.type == FillableFieldType.GenericPassword }) {
+ "Every rule block can only have either genericPassword or {current,new}Password blocks"
+ }
+ matchers.add(
+ AutofillRuleMatcher(
+ type = FillableFieldType.NewPassword,
+ matcher = FieldMatcher.Builder().apply(block).build(),
+ optional = optional,
+ matchHidden = false
+ )
+ )
+ }
- fun currentPassword(optional: Boolean = false, matchHidden: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
- require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
- matchers.add(
- AutofillRuleMatcher(
- type = FillableFieldType.CurrentPassword,
- matcher = FieldMatcher.Builder().apply(block).build(),
- optional = optional,
- matchHidden = matchHidden
- )
+ fun genericPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
+ require(
+ matchers.none {
+ it.type in
+ listOf(
+ FillableFieldType.CurrentPassword,
+ FillableFieldType.NewPassword,
)
}
+ ) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
+ matchers.add(
+ AutofillRuleMatcher(
+ type = FillableFieldType.GenericPassword,
+ matcher = FieldMatcher.Builder().apply(block).build(),
+ optional = optional,
+ matchHidden = false
+ )
+ )
+ }
- fun newPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
- require(matchers.none { it.type == FillableFieldType.GenericPassword }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
- matchers.add(
- AutofillRuleMatcher(
- type = FillableFieldType.NewPassword,
- matcher = FieldMatcher.Builder().apply(block).build(),
- optional = optional,
- matchHidden = false
- )
- )
+ fun build(): AutofillRule {
+ if (applyInSingleOriginMode) {
+ require(matchers.none { it.matcher is PairOfFieldsMatcher }) {
+ "Rules with applyInSingleOriginMode set to true must only match single fields"
}
-
- fun genericPassword(optional: Boolean = false, block: FieldMatcher.Builder.() -> Unit) {
- require(matchers.none {
- it.type in listOf(
- FillableFieldType.CurrentPassword,
- FillableFieldType.NewPassword,
- )
- }) { "Every rule block can only have either genericPassword or {current,new}Password blocks" }
- matchers.add(
- AutofillRuleMatcher(
- type = FillableFieldType.GenericPassword,
- matcher = FieldMatcher.Builder().apply(block).build(),
- optional = optional,
- matchHidden = false
- )
- )
+ require(matchers.filter { it.type != FillableFieldType.Username }.size <= 1) {
+ "Rules with applyInSingleOriginMode set to true must only match at most one password field"
}
-
- fun build(): AutofillRule {
- if (applyInSingleOriginMode) {
- require(matchers.none { it.matcher is PairOfFieldsMatcher }) { "Rules with applyInSingleOriginMode set to true must only match single fields" }
- require(matchers.filter { it.type != FillableFieldType.Username }.size <= 1) { "Rules with applyInSingleOriginMode set to true must only match at most one password field" }
- require(matchers.none { it.matchHidden }) { "Rules with applyInSingleOriginMode set to true must not fill into hidden fields" }
- }
- return AutofillRule(
- matchers, applyInSingleOriginMode, applyOnManualRequestOnly, name ?: "Rule #$ruleId"
- ).also { ruleId++ }
+ require(matchers.none { it.matchHidden }) {
+ "Rules with applyInSingleOriginMode set to true must not fill into hidden fields"
}
+ }
+ return AutofillRule(matchers, applyInSingleOriginMode, applyOnManualRequestOnly, name ?: "Rule #$ruleId").also {
+ ruleId++
+ }
}
-
- fun match(
- allPassword: List<FormField>,
- allUsername: List<FormField>,
- allOtp: List<FormField>,
- singleOriginMode: Boolean,
- isManualRequest: Boolean
- ): AutofillScenario<FormField>? {
- if (singleOriginMode && !applyInSingleOriginMode) {
- d { "$name: Skipped in single origin mode" }
- return null
- }
- if (!isManualRequest && applyOnManualRequestOnly) {
- d { "$name: Skipped since not a manual request" }
+ }
+
+ fun match(
+ allPassword: List<FormField>,
+ allUsername: List<FormField>,
+ allOtp: List<FormField>,
+ singleOriginMode: Boolean,
+ isManualRequest: Boolean
+ ): AutofillScenario<FormField>? {
+ if (singleOriginMode && !applyInSingleOriginMode) {
+ d { "$name: Skipped in single origin mode" }
+ return null
+ }
+ if (!isManualRequest && applyOnManualRequestOnly) {
+ d { "$name: Skipped since not a manual request" }
+ return null
+ }
+ d { "$name: Applying..." }
+ val scenarioBuilder = AutofillScenario.Builder<FormField>()
+ val alreadyMatched = mutableListOf<FormField>()
+ for ((type, matcher, optional, matchHidden) in matchers) {
+ val fieldsToMatchOn =
+ when (type) {
+ FillableFieldType.Username -> allUsername
+ FillableFieldType.Otp -> allOtp
+ else -> allPassword
+ }.filter { matchHidden || it.isVisible }
+ val matchResult =
+ matcher.match(fieldsToMatchOn, alreadyMatched)
+ ?: if (optional) {
+ d { "$name: Skipping optional $type matcher" }
+ continue
+ } else {
+ d { "$name: Required $type matcher didn't match; passing to next rule" }
return null
+ }
+ d { "$name: Matched $type" }
+ when (type) {
+ FillableFieldType.Username -> {
+ check(matchResult.size == 1 && scenarioBuilder.username == null)
+ scenarioBuilder.username = matchResult.single()
+ // Hidden username fields should be saved but not filled.
+ scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true
}
- d { "$name: Applying..." }
- val scenarioBuilder = AutofillScenario.Builder<FormField>()
- val alreadyMatched = mutableListOf<FormField>()
- for ((type, matcher, optional, matchHidden) in matchers) {
- val fieldsToMatchOn = when (type) {
- FillableFieldType.Username -> allUsername
- FillableFieldType.Otp -> allOtp
- else -> allPassword
- }.filter { matchHidden || it.isVisible }
- val matchResult = matcher.match(fieldsToMatchOn, alreadyMatched) ?: if (optional) {
- d { "$name: Skipping optional $type matcher" }
- continue
- } else {
- d { "$name: Required $type matcher didn't match; passing to next rule" }
- return null
- }
- d { "$name: Matched $type" }
- when (type) {
- FillableFieldType.Username -> {
- check(matchResult.size == 1 && scenarioBuilder.username == null)
- scenarioBuilder.username = matchResult.single()
- // Hidden username fields should be saved but not filled.
- scenarioBuilder.fillUsername = scenarioBuilder.username!!.isVisible == true
- }
- FillableFieldType.Otp -> {
- check(matchResult.size == 1 && scenarioBuilder.otp == null)
- scenarioBuilder.otp = matchResult.single()
- }
- FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll(
- matchResult
- )
- FillableFieldType.NewPassword -> scenarioBuilder.newPassword.addAll(matchResult)
- FillableFieldType.GenericPassword -> scenarioBuilder.genericPassword.addAll(
- matchResult
- )
- }
- alreadyMatched.addAll(matchResult)
+ FillableFieldType.Otp -> {
+ check(matchResult.size == 1 && scenarioBuilder.otp == null)
+ scenarioBuilder.otp = matchResult.single()
}
- return scenarioBuilder.build().takeIf { scenario ->
- scenario.passesOriginCheck(singleOriginMode = singleOriginMode).also { passed ->
- if (passed) {
- d { "$name: Detected scenario:\n$scenario" }
- } else {
- w { "$name: Scenario failed origin check:\n$scenario" }
- }
- }
+ FillableFieldType.CurrentPassword -> scenarioBuilder.currentPassword.addAll(matchResult)
+ FillableFieldType.NewPassword -> scenarioBuilder.newPassword.addAll(matchResult)
+ FillableFieldType.GenericPassword -> scenarioBuilder.genericPassword.addAll(matchResult)
+ }
+ alreadyMatched.addAll(matchResult)
+ }
+ return scenarioBuilder.build().takeIf { scenario ->
+ scenario.passesOriginCheck(singleOriginMode = singleOriginMode).also { passed ->
+ if (passed) {
+ d { "$name: Detected scenario:\n$scenario" }
+ } else {
+ w { "$name: Scenario failed origin check:\n$scenario" }
}
+ }
}
+ }
}
@RequiresApi(Build.VERSION_CODES.O)
internal class AutofillStrategy private constructor(private val rules: List<AutofillRule>) {
- @AutofillDsl
- class Builder {
-
- private val rules: MutableList<AutofillRule> = mutableListOf()
-
- fun rule(
- applyInSingleOriginMode: Boolean = false,
- applyOnManualRequestOnly: Boolean = false,
- block: AutofillRule.Builder.() -> Unit
- ) {
- rules.add(
- AutofillRule.Builder(
- applyInSingleOriginMode = applyInSingleOriginMode,
- applyOnManualRequestOnly = applyOnManualRequestOnly
- ).apply(block).build()
- )
- }
+ @AutofillDsl
+ class Builder {
+
+ private val rules: MutableList<AutofillRule> = mutableListOf()
- fun build() = AutofillStrategy(rules)
+ fun rule(
+ applyInSingleOriginMode: Boolean = false,
+ applyOnManualRequestOnly: Boolean = false,
+ block: AutofillRule.Builder.() -> Unit
+ ) {
+ rules.add(
+ AutofillRule.Builder(
+ applyInSingleOriginMode = applyInSingleOriginMode,
+ applyOnManualRequestOnly = applyOnManualRequestOnly
+ )
+ .apply(block)
+ .build()
+ )
}
- fun match(
- fields: List<FormField>,
- singleOriginMode: Boolean,
- isManualRequest: Boolean
- ): AutofillScenario<FormField>? {
- val possiblePasswordFields =
- fields.filter { it.passwordCertainty >= CertaintyLevel.Possible }
- d { "Possible password fields: ${possiblePasswordFields.size}" }
- val possibleUsernameFields =
- fields.filter { it.usernameCertainty >= CertaintyLevel.Possible }
- d { "Possible username fields: ${possibleUsernameFields.size}" }
- val possibleOtpFields =
- fields.filter { it.otpCertainty >= CertaintyLevel.Possible }
- d { "Possible otp fields: ${possibleOtpFields.size}" }
- // Return the result of the first rule that matches
- d { "Rules: ${rules.size}" }
- for (rule in rules) {
- return rule.match(
- possiblePasswordFields,
- possibleUsernameFields,
- possibleOtpFields,
- singleOriginMode = singleOriginMode,
- isManualRequest = isManualRequest
- )
- ?: continue
- }
- return null
+ fun build() = AutofillStrategy(rules)
+ }
+
+ fun match(
+ fields: List<FormField>,
+ singleOriginMode: Boolean,
+ isManualRequest: Boolean
+ ): AutofillScenario<FormField>? {
+ val possiblePasswordFields = fields.filter { it.passwordCertainty >= CertaintyLevel.Possible }
+ d { "Possible password fields: ${possiblePasswordFields.size}" }
+ val possibleUsernameFields = fields.filter { it.usernameCertainty >= CertaintyLevel.Possible }
+ d { "Possible username fields: ${possibleUsernameFields.size}" }
+ val possibleOtpFields = fields.filter { it.otpCertainty >= CertaintyLevel.Possible }
+ d { "Possible otp fields: ${possibleOtpFields.size}" }
+ // Return the result of the first rule that matches
+ d { "Rules: ${rules.size}" }
+ for (rule in rules) {
+ return rule.match(
+ possiblePasswordFields,
+ possibleUsernameFields,
+ possibleOtpFields,
+ singleOriginMode = singleOriginMode,
+ isManualRequest = isManualRequest
+ )
+ ?: continue
}
+ return null
+ }
}
-internal fun strategy(block: AutofillStrategy.Builder.() -> Unit) =
- AutofillStrategy.Builder().apply(block).build()
+internal fun strategy(block: AutofillStrategy.Builder.() -> Unit) = AutofillStrategy.Builder().apply(block).build()
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt
index 6d7bd7fb..8dba907c 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FeatureAndTrustDetection.kt
@@ -14,40 +14,40 @@ import android.service.autofill.SaveInfo
import androidx.annotation.RequiresApi
/*
- In order to add a new browser, do the following:
+ In order to add a new browser, do the following:
- 1. Obtain the .apk from a trusted source. For example, download it from the Play Store on your
- phone and use adb pull to get it onto your computer. We will assume that it is called
- browser.apk.
+ 1. Obtain the .apk from a trusted source. For example, download it from the Play Store on your
+ phone and use adb pull to get it onto your computer. We will assume that it is called
+ browser.apk.
- 2. Run
+ 2. Run
- aapt dump badging browser.apk | grep package: | grep -Eo " name='[a-zA-Z0-9_\.]*" | cut -c8-
+ aapt dump badging browser.apk | grep package: | grep -Eo " name='[a-zA-Z0-9_\.]*" | cut -c8-
- to obtain the package name (actually, the application ID) of the app in the .apk.
+ to obtain the package name (actually, the application ID) of the app in the .apk.
- 3. Run
+ 3. Run
- apksigner verify --print-certs browser.apk | grep "#1 certificate SHA-256" | grep -Eo "[a-f0-9]{64}" | tr -d '\n' | xxd -r -p | base64
+ apksigner verify --print-certs browser.apk | grep "#1 certificate SHA-256" | grep -Eo "[a-f0-9]{64}" | tr -d '\n' | xxd -r -p | base64
- to calculate the hash of browser.apk's first signing certificate.
- Note: This will only work if the apk has a single signing certificate. Apps with multiple
- signers are very rare, so there is probably no need to add them.
- Refer to computeCertificatesHash to learn how the hash would be computed in this case.
+ to calculate the hash of browser.apk's first signing certificate.
+ Note: This will only work if the apk has a single signing certificate. Apps with multiple
+ signers are very rare, so there is probably no need to add them.
+ Refer to computeCertificatesHash to learn how the hash would be computed in this case.
- 4. Verify the package name and the hash, for example by asking other people to repeat the steps
- above.
+ 4. Verify the package name and the hash, for example by asking other people to repeat the steps
+ above.
- 5. Add an entry with the browser apps's package name and the hash to
- TRUSTED_BROWSER_CERTIFICATE_HASH.
+ 5. Add an entry with the browser apps's package name and the hash to
+ TRUSTED_BROWSER_CERTIFICATE_HASH.
- 6. Optionally, try adding the browser's package name to BROWSERS_WITH_SAVE_SUPPORT and check
- whether a save request to Password Store is triggered when you submit a registration form.
+ 6. Optionally, try adding the browser's package name to BROWSERS_WITH_SAVE_SUPPORT and check
+ whether a save request to Password Store is triggered when you submit a registration form.
- 7. Optionally, try adding the browser's package name to BROWSERS_WITH_MULTI_ORIGIN_SUPPORT and
- check whether it correctly distinguishes web origins even if iframes are present on the page.
- You can use https://fabianhenneke.github.io/Android-Password-Store/ as a test form.
- */
+ 7. Optionally, try adding the browser's package name to BROWSERS_WITH_MULTI_ORIGIN_SUPPORT and
+ check whether it correctly distinguishes web origins even if iframes are present on the page.
+ You can use https://fabianhenneke.github.io/Android-Password-Store/ as a test form.
+*/
/*
* **Security assumption**: Browsers on this list correctly report the web origin of the top-level
@@ -56,13 +56,15 @@ import androidx.annotation.RequiresApi
* Note: Browsers can be on this list even if they don't report the correct web origins of all
* fields on the page, e.g. of those in iframes.
*/
-private val TRUSTED_BROWSER_CERTIFICATE_HASH = mapOf(
+private val TRUSTED_BROWSER_CERTIFICATE_HASH =
+ mapOf(
"com.android.chrome" to arrayOf("8P1sW0EPJcslw7UzRsiXL64w+O50Ed+RBICtay1g24M="),
"com.brave.browser" to arrayOf("nC23BRNRX9v7vFhbPt89cSPU3GfJT/0wY2HB15u/GKw="),
"com.chrome.beta" to arrayOf("2mM9NLaeY64hA7SdU84FL8X388U6q5T9wqIIvf0UJJw="),
"com.chrome.canary" to arrayOf("IBnfofsj779wxbzRRDxb6rBPPy/0Nm6aweNFdjmiTPw="),
"com.chrome.dev" to arrayOf("kETuX+5LvF4h3URmVDHE6x8fcaMnFqC8knvLs5Izyr8="),
- "com.duckduckgo.mobile.android" to arrayOf("u3uzHFc8RqHaf8XFKKas9DIQhFb+7FCBDH8zaU6z0tQ=", "8HB9AhwL8+b43MEbo/VwBCXVl9yjAaMeIQVWk067Gwo="),
+ "com.duckduckgo.mobile.android" to
+ arrayOf("u3uzHFc8RqHaf8XFKKas9DIQhFb+7FCBDH8zaU6z0tQ=", "8HB9AhwL8+b43MEbo/VwBCXVl9yjAaMeIQVWk067Gwo="),
"com.microsoft.emmx" to arrayOf("AeGZlxCoLCdJtNUMRF3IXWcLYTYInQp2anOCfIKh6sk="),
"com.opera.mini.native" to arrayOf("V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I="),
"com.opera.mini.native.beta" to arrayOf("V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I="),
@@ -81,16 +83,18 @@ private val TRUSTED_BROWSER_CERTIFICATE_HASH = mapOf(
"org.ungoogled.chromium.stable" to arrayOf("29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk="),
"org.ungoogled.chromium.extensions.stable" to arrayOf("29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk="),
"com.kiwibrowser.browser" to arrayOf("wGnqlmMy6R4KDDzFd+b1Cf49ndr3AVrQxcXvj9o/hig="),
-)
+ )
private fun isTrustedBrowser(context: Context, appPackage: String): Boolean {
- val expectedCertificateHashes = TRUSTED_BROWSER_CERTIFICATE_HASH[appPackage] ?: return false
- val certificateHash = computeCertificatesHash(context, appPackage)
- return certificateHash in expectedCertificateHashes
+ val expectedCertificateHashes = TRUSTED_BROWSER_CERTIFICATE_HASH[appPackage] ?: return false
+ val certificateHash = computeCertificatesHash(context, appPackage)
+ return certificateHash in expectedCertificateHashes
}
internal enum class BrowserMultiOriginMethod {
- None, WebView, Field
+ None,
+ WebView,
+ Field
}
/**
@@ -100,11 +104,12 @@ internal enum class BrowserMultiOriginMethod {
*
* There are two methods used by browsers:
* - Browsers based on Android's WebView report web domains on each WebView view node, which then
- * needs to be propagated to the child nodes ([BrowserMultiOriginMethod.WebView]).
+ * needs to be propagated to the child nodes ( [BrowserMultiOriginMethod.WebView]).
* - Browsers with custom Autofill implementations report web domains on each input field (
- * [BrowserMultiOriginMethod.Field]).
+ * [BrowserMultiOriginMethod.Field]).
*/
-private val BROWSER_MULTI_ORIGIN_METHOD = mapOf(
+private val BROWSER_MULTI_ORIGIN_METHOD =
+ mapOf(
"com.duckduckgo.mobile.android" to BrowserMultiOriginMethod.WebView,
"com.opera.mini.native" to BrowserMultiOriginMethod.WebView,
"com.opera.mini.native.beta" to BrowserMultiOriginMethod.WebView,
@@ -119,10 +124,10 @@ private val BROWSER_MULTI_ORIGIN_METHOD = mapOf(
"org.mozilla.focus" to BrowserMultiOriginMethod.Field,
"org.mozilla.klar" to BrowserMultiOriginMethod.Field,
"org.torproject.torbrowser" to BrowserMultiOriginMethod.WebView,
-)
+ )
private fun getBrowserMultiOriginMethod(appPackage: String): BrowserMultiOriginMethod =
- BROWSER_MULTI_ORIGIN_METHOD[appPackage] ?: BrowserMultiOriginMethod.None
+ BROWSER_MULTI_ORIGIN_METHOD[appPackage] ?: BrowserMultiOriginMethod.None
/**
* Browsers on this list issue Autofill save requests and provide unmasked passwords as
@@ -132,7 +137,8 @@ private fun getBrowserMultiOriginMethod(appPackage: String): BrowserMultiOriginM
* `FLAG_SAVE_ON_ALL_VIEW_INVISIBLE` to be set.
*/
@RequiresApi(Build.VERSION_CODES.O)
-private val BROWSER_SAVE_FLAG = mapOf(
+private val BROWSER_SAVE_FLAG =
+ mapOf(
"com.duckduckgo.mobile.android" to 0,
"org.mozilla.klar" to 0,
"org.mozilla.focus" to 0,
@@ -142,89 +148,77 @@ private val BROWSER_SAVE_FLAG = mapOf(
"com.opera.mini.native" to 0,
"com.opera.mini.native.beta" to 0,
"com.opera.touch" to 0,
-)
+ )
@RequiresApi(Build.VERSION_CODES.O)
-private val BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY = mapOf(
+private val BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY =
+ mapOf(
"com.android.chrome" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
"com.chrome.beta" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
"com.chrome.canary" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
"com.chrome.dev" to SaveInfo.FLAG_SAVE_ON_ALL_VIEWS_INVISIBLE,
-)
+ )
private fun isNoAccessibilityServiceEnabled(context: Context): Boolean {
- // See https://chromium.googlesource.com/chromium/src/+/447a31e977a65e2eb78804e4a09633699b4ede33
- return Settings.Secure.getString(context.contentResolver,
- Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES).isNullOrEmpty()
+ // See https://chromium.googlesource.com/chromium/src/+/447a31e977a65e2eb78804e4a09633699b4ede33
+ return Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES)
+ .isNullOrEmpty()
}
@RequiresApi(Build.VERSION_CODES.O)
private fun getBrowserSaveFlag(context: Context, appPackage: String): Int? =
- BROWSER_SAVE_FLAG[appPackage] ?: BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY[appPackage]?.takeIf {
- isNoAccessibilityServiceEnabled(context)
- }
+ BROWSER_SAVE_FLAG[appPackage]
+ ?: BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY[appPackage]?.takeIf { isNoAccessibilityServiceEnabled(context) }
-internal data class BrowserAutofillSupportInfo(
- val multiOriginMethod: BrowserMultiOriginMethod,
- val saveFlags: Int?
-)
+internal data class BrowserAutofillSupportInfo(val multiOriginMethod: BrowserMultiOriginMethod, val saveFlags: Int?)
@RequiresApi(Build.VERSION_CODES.O)
-internal fun getBrowserAutofillSupportInfoIfTrusted(
- context: Context,
- appPackage: String
-): BrowserAutofillSupportInfo? {
- if (!isTrustedBrowser(context, appPackage)) return null
- return BrowserAutofillSupportInfo(
- multiOriginMethod = getBrowserMultiOriginMethod(appPackage),
- saveFlags = getBrowserSaveFlag(context, appPackage)
- )
+internal fun getBrowserAutofillSupportInfoIfTrusted(context: Context, appPackage: String): BrowserAutofillSupportInfo? {
+ if (!isTrustedBrowser(context, appPackage)) return null
+ return BrowserAutofillSupportInfo(
+ multiOriginMethod = getBrowserMultiOriginMethod(appPackage),
+ saveFlags = getBrowserSaveFlag(context, appPackage)
+ )
}
-private val FLAKY_BROWSERS = listOf(
+private val FLAKY_BROWSERS =
+ listOf(
"org.bromite.bromite",
"org.ungoogled.chromium.stable",
"com.kiwibrowser.browser",
-)
+ )
public enum class BrowserAutofillSupportLevel {
- None,
- FlakyFill,
- PasswordFill,
- PasswordFillAndSaveIfNoAccessibility,
- GeneralFill,
- GeneralFillAndSave,
+ None,
+ FlakyFill,
+ PasswordFill,
+ PasswordFillAndSaveIfNoAccessibility,
+ GeneralFill,
+ GeneralFillAndSave,
}
@RequiresApi(Build.VERSION_CODES.O)
-private fun getBrowserAutofillSupportLevel(
- context: Context,
- appPackage: String
-): BrowserAutofillSupportLevel {
- val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
- return when {
- browserInfo == null -> BrowserAutofillSupportLevel.None
- appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill
- appPackage in BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY -> BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility
- browserInfo.multiOriginMethod == BrowserMultiOriginMethod.None -> BrowserAutofillSupportLevel.PasswordFill
- browserInfo.saveFlags == null -> BrowserAutofillSupportLevel.GeneralFill
- else -> BrowserAutofillSupportLevel.GeneralFillAndSave
- }
+private fun getBrowserAutofillSupportLevel(context: Context, appPackage: String): BrowserAutofillSupportLevel {
+ val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
+ return when {
+ browserInfo == null -> BrowserAutofillSupportLevel.None
+ appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill
+ appPackage in BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY ->
+ BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility
+ browserInfo.multiOriginMethod == BrowserMultiOriginMethod.None -> BrowserAutofillSupportLevel.PasswordFill
+ browserInfo.saveFlags == null -> BrowserAutofillSupportLevel.GeneralFill
+ else -> BrowserAutofillSupportLevel.GeneralFillAndSave
+ }
}
@RequiresApi(Build.VERSION_CODES.O)
-public fun getInstalledBrowsersWithAutofillSupportLevel(context: Context): List<Pair<String, BrowserAutofillSupportLevel>> {
- val testWebIntent = Intent(Intent.ACTION_VIEW).apply {
- data = Uri.parse("http://example.org")
- }
- val installedBrowsers = context.packageManager.queryIntentActivities(
- testWebIntent,
- PackageManager.MATCH_ALL
- )
- return installedBrowsers.map {
- it to getBrowserAutofillSupportLevel(context, it.activityInfo.packageName)
- }.filter { it.first.isDefault || it.second != BrowserAutofillSupportLevel.None }.map {
- context.packageManager.getApplicationLabel(it.first.activityInfo.applicationInfo)
- .toString() to it.second
- }
+public fun getInstalledBrowsersWithAutofillSupportLevel(
+ context: Context
+): List<Pair<String, BrowserAutofillSupportLevel>> {
+ val testWebIntent = Intent(Intent.ACTION_VIEW).apply { data = Uri.parse("http://example.org") }
+ val installedBrowsers = context.packageManager.queryIntentActivities(testWebIntent, PackageManager.MATCH_ALL)
+ return installedBrowsers
+ .map { it to getBrowserAutofillSupportLevel(context, it.activityInfo.packageName) }
+ .filter { it.first.isDefault || it.second != BrowserAutofillSupportLevel.None }
+ .map { context.packageManager.getApplicationLabel(it.first.activityInfo.applicationInfo).toString() to it.second }
}
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt
index f5e980dc..d3ce8408 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/FormField.kt
@@ -14,291 +14,321 @@ import androidx.autofill.HintConstants
import java.util.Locale
internal enum class CertaintyLevel {
- Impossible, Possible, Likely, Certain
+ Impossible,
+ Possible,
+ Likely,
+ Certain
}
/**
- * Represents a single potentially fillable or saveable field together with all meta data
- * extracted from its [AssistStructure.ViewNode].
+ * Represents a single potentially fillable or saveable field together with all meta data extracted
+ * from its [AssistStructure.ViewNode].
*/
@RequiresApi(Build.VERSION_CODES.O)
internal class FormField(
- node: AssistStructure.ViewNode,
- private val index: Int,
- passDownWebViewOrigins: Boolean,
- passedDownWebOrigin: String? = null
+ node: AssistStructure.ViewNode,
+ private val index: Int,
+ passDownWebViewOrigins: Boolean,
+ passedDownWebOrigin: String? = null
) {
- companion object {
+ companion object {
- private val HINTS_USERNAME = listOf(
- HintConstants.AUTOFILL_HINT_USERNAME,
- HintConstants.AUTOFILL_HINT_NEW_USERNAME,
- )
-
- private val HINTS_NEW_PASSWORD = listOf(
- HintConstants.AUTOFILL_HINT_NEW_PASSWORD,
- )
-
- private val HINTS_PASSWORD = HINTS_NEW_PASSWORD + listOf(
- HintConstants.AUTOFILL_HINT_PASSWORD,
- )
+ private val HINTS_USERNAME =
+ listOf(
+ HintConstants.AUTOFILL_HINT_USERNAME,
+ HintConstants.AUTOFILL_HINT_NEW_USERNAME,
+ )
- private val HINTS_OTP = listOf(
- HintConstants.AUTOFILL_HINT_SMS_OTP,
- )
+ private val HINTS_NEW_PASSWORD =
+ listOf(
+ HintConstants.AUTOFILL_HINT_NEW_PASSWORD,
+ )
- @Suppress("DEPRECATION")
- private val HINTS_FILLABLE = HINTS_USERNAME + HINTS_PASSWORD + HINTS_OTP + listOf(
- HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS,
- HintConstants.AUTOFILL_HINT_NAME,
- HintConstants.AUTOFILL_HINT_PERSON_NAME,
- HintConstants.AUTOFILL_HINT_PHONE,
- HintConstants.AUTOFILL_HINT_PHONE_NUMBER,
+ private val HINTS_PASSWORD =
+ HINTS_NEW_PASSWORD +
+ listOf(
+ HintConstants.AUTOFILL_HINT_PASSWORD,
)
- private val ANDROID_TEXT_FIELD_CLASS_NAMES = listOf(
- "android.widget.EditText",
- "android.widget.AutoCompleteTextView",
- "androidx.appcompat.widget.AppCompatEditText",
- "android.support.v7.widget.AppCompatEditText",
- "com.google.android.material.textfield.TextInputEditText",
+ private val HINTS_OTP =
+ listOf(
+ HintConstants.AUTOFILL_HINT_SMS_OTP,
+ )
+
+ @Suppress("DEPRECATION")
+ private val HINTS_FILLABLE =
+ HINTS_USERNAME +
+ HINTS_PASSWORD +
+ HINTS_OTP +
+ listOf(
+ HintConstants.AUTOFILL_HINT_EMAIL_ADDRESS,
+ HintConstants.AUTOFILL_HINT_NAME,
+ HintConstants.AUTOFILL_HINT_PERSON_NAME,
+ HintConstants.AUTOFILL_HINT_PHONE,
+ HintConstants.AUTOFILL_HINT_PHONE_NUMBER,
)
- private const val ANDROID_WEB_VIEW_CLASS_NAME = "android.webkit.WebView"
-
- private fun isPasswordInputType(inputType: Int): Boolean {
- val typeClass = inputType and InputType.TYPE_MASK_CLASS
- val typeVariation = inputType and InputType.TYPE_MASK_VARIATION
- return when (typeClass) {
- InputType.TYPE_CLASS_NUMBER -> typeVariation == InputType.TYPE_NUMBER_VARIATION_PASSWORD
- InputType.TYPE_CLASS_TEXT -> typeVariation in listOf(
- InputType.TYPE_TEXT_VARIATION_PASSWORD,
- InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD,
- InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD,
- )
- else -> false
- }
- }
-
- private val HTML_INPUT_FIELD_TYPES_USERNAME = listOf(
- "email",
- "tel",
- "text",
- )
- private val HTML_INPUT_FIELD_TYPES_PASSWORD = listOf(
- "password",
- )
- private val HTML_INPUT_FIELD_TYPES_OTP = listOf(
- "tel",
- "text",
- )
- private val HTML_INPUT_FIELD_TYPES_FILLABLE =
- (HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList()
-
- @RequiresApi(Build.VERSION_CODES.O)
- private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE
-
- private val EXCLUDED_TERMS = listOf(
- "url_bar", // Chrome/Edge/Firefox address bar
- "url_field", // Opera address bar
- "location_bar_edit_text", // Samsung address bar
- "search", "find", "captcha",
- "postal", // Prevent postal code fields from being mistaken for OTP fields
-
- )
- private val PASSWORD_HEURISTIC_TERMS = listOf(
- "pass",
- "pswd",
- "pwd",
- )
- private val USERNAME_HEURISTIC_TERMS = listOf(
- "alias",
- "benutzername",
- "e-mail",
- "email",
- "login",
- "user",
- )
- private val OTP_HEURISTIC_TERMS = listOf(
- "einmal",
- "otp",
- "challenge",
- "verification",
- )
- private val OTP_WEAK_HEURISTIC_TERMS = listOf(
- "code",
- )
+ private val ANDROID_TEXT_FIELD_CLASS_NAMES =
+ listOf(
+ "android.widget.EditText",
+ "android.widget.AutoCompleteTextView",
+ "androidx.appcompat.widget.AppCompatEditText",
+ "android.support.v7.widget.AppCompatEditText",
+ "com.google.android.material.textfield.TextInputEditText",
+ )
+
+ private const val ANDROID_WEB_VIEW_CLASS_NAME = "android.webkit.WebView"
+
+ private fun isPasswordInputType(inputType: Int): Boolean {
+ val typeClass = inputType and InputType.TYPE_MASK_CLASS
+ val typeVariation = inputType and InputType.TYPE_MASK_VARIATION
+ return when (typeClass) {
+ InputType.TYPE_CLASS_NUMBER -> typeVariation == InputType.TYPE_NUMBER_VARIATION_PASSWORD
+ InputType.TYPE_CLASS_TEXT ->
+ typeVariation in
+ listOf(
+ InputType.TYPE_TEXT_VARIATION_PASSWORD,
+ InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD,
+ InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD,
+ )
+ else -> false
+ }
}
- private val List<String>.anyMatchesFieldInfo
- get() = any {
- fieldId.contains(it) || hint.contains(it) || htmlName.contains(it)
- }
-
- val autofillId: AutofillId = node.autofillId!!
-
- // Information for heuristics and exclusion rules based only on the current field
- private val htmlId = node.htmlInfo?.attributes?.firstOrNull { it.first == "id" }?.second
- private val resourceId = node.idEntry
- private val fieldId = (htmlId ?: resourceId ?: "").toLowerCase(Locale.US)
- private val hint = node.hint?.toLowerCase(Locale.US) ?: ""
- private val className: String? = node.className
- private val inputType = node.inputType
-
- // Information for advanced heuristics taking multiple fields and page context into account
- val isFocused = node.isFocused
-
- // The webOrigin of a WebView should be passed down to its children in certain browsers
- private val isWebView = node.className == ANDROID_WEB_VIEW_CLASS_NAME
- val webOrigin = node.webOrigin ?: if (passDownWebViewOrigins) passedDownWebOrigin else null
- val webOriginToPassDown = if (passDownWebViewOrigins) {
- if (isWebView) webOrigin else passedDownWebOrigin
+ private val HTML_INPUT_FIELD_TYPES_USERNAME =
+ listOf(
+ "email",
+ "tel",
+ "text",
+ )
+ private val HTML_INPUT_FIELD_TYPES_PASSWORD =
+ listOf(
+ "password",
+ )
+ private val HTML_INPUT_FIELD_TYPES_OTP =
+ listOf(
+ "tel",
+ "text",
+ )
+ private val HTML_INPUT_FIELD_TYPES_FILLABLE =
+ (HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList()
+
+ @RequiresApi(Build.VERSION_CODES.O) private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE
+
+ private val EXCLUDED_TERMS =
+ listOf(
+ "url_bar", // Chrome/Edge/Firefox address bar
+ "url_field", // Opera address bar
+ "location_bar_edit_text", // Samsung address bar
+ "search",
+ "find",
+ "captcha",
+ "postal", // Prevent postal code fields from being mistaken for OTP fields
+ )
+ private val PASSWORD_HEURISTIC_TERMS =
+ listOf(
+ "pass",
+ "pswd",
+ "pwd",
+ )
+ private val USERNAME_HEURISTIC_TERMS =
+ listOf(
+ "alias",
+ "benutzername",
+ "e-mail",
+ "email",
+ "login",
+ "user",
+ )
+ private val OTP_HEURISTIC_TERMS =
+ listOf(
+ "einmal",
+ "otp",
+ "challenge",
+ "verification",
+ )
+ private val OTP_WEAK_HEURISTIC_TERMS =
+ listOf(
+ "code",
+ )
+ }
+
+ private val List<String>.anyMatchesFieldInfo
+ get() = any { fieldId.contains(it) || hint.contains(it) || htmlName.contains(it) }
+
+ val autofillId: AutofillId = node.autofillId!!
+
+ // Information for heuristics and exclusion rules based only on the current field
+ private val htmlId = node.htmlInfo?.attributes?.firstOrNull { it.first == "id" }?.second
+ private val resourceId = node.idEntry
+ private val fieldId = (htmlId ?: resourceId ?: "").toLowerCase(Locale.US)
+ private val hint = node.hint?.toLowerCase(Locale.US) ?: ""
+ private val className: String? = node.className
+ private val inputType = node.inputType
+
+ // Information for advanced heuristics taking multiple fields and page context into account
+ val isFocused = node.isFocused
+
+ // The webOrigin of a WebView should be passed down to its children in certain browsers
+ private val isWebView = node.className == ANDROID_WEB_VIEW_CLASS_NAME
+ val webOrigin = node.webOrigin ?: if (passDownWebViewOrigins) passedDownWebOrigin else null
+ val webOriginToPassDown =
+ if (passDownWebViewOrigins) {
+ if (isWebView) webOrigin else passedDownWebOrigin
} else {
- null
- }
-
- // Basic type detection for HTML fields
- private val htmlTag = node.htmlInfo?.tag
- private val htmlAttributes: Map<String, String> =
- node.htmlInfo?.attributes?.filter { it.first != null && it.second != null }
- ?.associate { Pair(it.first.toLowerCase(Locale.US), it.second.toLowerCase(Locale.US)) }
- ?: emptyMap()
-
- private val htmlAttributesDebug =
- htmlAttributes.entries.joinToString { "${it.key}=${it.value}" }
- private val htmlInputType = htmlAttributes["type"]
- private val htmlName = htmlAttributes["name"] ?: ""
- private val htmlMaxLength = htmlAttributes["maxlength"]?.toIntOrNull()
- private val isHtmlField = htmlTag == "input"
- private val isHtmlPasswordField =
- isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_PASSWORD
- private val isHtmlTextField = isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_FILLABLE
-
- // Basic type detection for native fields
- private val hasPasswordInputType = isPasswordInputType(inputType)
-
- // HTML fields with non-fillable types (such as submit buttons) should be excluded here
- private val isAndroidTextField = !isHtmlField && className in ANDROID_TEXT_FIELD_CLASS_NAMES
- private val isAndroidPasswordField = isAndroidTextField && hasPasswordInputType
-
- private val isTextField = isAndroidTextField || isHtmlTextField
-
- // Autofill hint detection for native fields
- private val autofillHints = node.autofillHints?.filter { isSupportedHint(it) } ?: emptyList()
- private val excludedByAutofillHints =
- if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty()
- val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty()
- private val hasAutofillHintNewPassword = autofillHints.intersect(HINTS_NEW_PASSWORD).isNotEmpty()
- private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty()
- private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty()
-
- // W3C autocomplete hint detection for HTML fields
- private val htmlAutocomplete = htmlAttributes["autocomplete"]
-
- // Ignored for now, see excludedByHints
- private val excludedByAutocompleteHint = htmlAutocomplete == "off"
- private val hasAutocompleteHintUsername = htmlAutocomplete == "username"
- val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password"
- private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password"
- private val hasAutocompleteHintPassword =
- hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword
- private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code"
-
- // Results of hint-based field type detection
- val hasHintUsername = hasAutofillHintUsername || hasAutocompleteHintUsername
- val hasHintPassword = hasAutofillHintPassword || hasAutocompleteHintPassword
- val hasHintNewPassword = hasAutofillHintNewPassword || hasAutocompleteHintNewPassword
- val hasHintOtp = hasAutofillHintOtp || hasAutocompleteHintOtp
-
- // Basic autofill exclusion checks
- private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT
- val isVisible = node.visibility == View.VISIBLE && htmlAttributes["aria-hidden"] != "true"
-
- // Hidden username fields are used to help password managers save credentials in two-step login
- // flows.
- // See: https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
- val couldBeTwoStepHiddenUsername = !isVisible && isHtmlTextField && hasAutocompleteHintUsername
-
- // Some websites with two-step login flows offer hidden password fields to fill the password
- // already in the first step. Thus, we delegate the decision about filling invisible password
- // fields to the fill rules and only exclude those fields that have incompatible autocomplete
- // hint.
- val couldBeTwoStepHiddenPassword =
- !isVisible && isHtmlPasswordField && (hasAutocompleteHintCurrentPassword || htmlAutocomplete == null)
-
- // Since many site put autocomplete=off on login forms for compliance reasons or since they are
- // worried of the user's browser automatically (i.e., without any user interaction) filling
- // them, which we never do, we choose to ignore the value of excludedByAutocompleteHint.
- // TODO: Revisit this decision in the future
- private val excludedByHints = excludedByAutofillHints
-
- // Only offer to fill into custom views if they explicitly opted into Autofill.
- val relevantField = hasAutofillTypeText && (isTextField || autofillHints.isNotEmpty()) && !excludedByHints
-
- // Exclude fields based on hint, resource ID or HTML name.
- // Note: We still report excluded fields as relevant since they count for adjacency heuristics,
- // but ensure that they are never detected as password or username fields.
- private val hasExcludedTerm = EXCLUDED_TERMS.anyMatchesFieldInfo
- private val notExcluded = relevantField && !hasExcludedTerm
-
- // Password field heuristics (based only on the current field)
- private val isPossiblePasswordField =
- notExcluded && (isAndroidPasswordField || isHtmlPasswordField || hasHintPassword)
- private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword
- private val isLikelyPasswordField = isPossiblePasswordField &&
- (isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo)
- val passwordCertainty =
- if (isCertainPasswordField) CertaintyLevel.Certain else if (isLikelyPasswordField) CertaintyLevel.Likely else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible
-
- // OTP field heuristics (based only on the current field)
- private val isPossibleOtpField = notExcluded && !isPossiblePasswordField
- private val isCertainOtpField = isPossibleOtpField && hasHintOtp
- private val isLikelyOtpField = isPossibleOtpField && (
- isCertainOtpField || OTP_HEURISTIC_TERMS.anyMatchesFieldInfo ||
- ((htmlMaxLength == null || htmlMaxLength in 6..8) && OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo))
- val otpCertainty =
- if (isCertainOtpField) CertaintyLevel.Certain else if (isLikelyOtpField) CertaintyLevel.Likely else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible
-
- // Username field heuristics (based only on the current field)
- private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField
- private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername
- private val isLikelyUsernameField = isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo))
- val usernameCertainty =
- if (isCertainUsernameField) CertaintyLevel.Certain else if (isLikelyUsernameField) CertaintyLevel.Likely else if (isPossibleUsernameField) CertaintyLevel.Possible else CertaintyLevel.Impossible
-
- infix fun directlyPrecedes(that: FormField?): Boolean {
- return index == (that ?: return false).index - 1
- }
-
- infix fun directlyPrecedes(that: Iterable<FormField>): Boolean {
- val firstIndex = that.map { it.index }.minOrNull() ?: return false
- return index == firstIndex - 1
- }
-
- infix fun directlyFollows(that: FormField?): Boolean {
- return index == (that ?: return false).index + 1
- }
-
- infix fun directlyFollows(that: Iterable<FormField>): Boolean {
- val lastIndex = that.map { it.index }.maxOrNull() ?: return false
- return index == lastIndex + 1
- }
-
- override fun toString(): String {
- val field = if (isHtmlTextField) "$htmlTag[type=$htmlInputType]" else className
- val description =
- "\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug, $autofillHints"
- return "$field ($description): password=$passwordCertainty, username=$usernameCertainty, otp=$otpCertainty"
- }
-
- override fun equals(other: Any?): Boolean {
- if (other == null) return false
- if (this.javaClass != other.javaClass) return false
- return autofillId == (other as FormField).autofillId
+ null
}
- override fun hashCode(): Int {
- return autofillId.hashCode()
+ // Basic type detection for HTML fields
+ private val htmlTag = node.htmlInfo?.tag
+ private val htmlAttributes: Map<String, String> =
+ node.htmlInfo?.attributes?.filter { it.first != null && it.second != null }?.associate {
+ Pair(it.first.toLowerCase(Locale.US), it.second.toLowerCase(Locale.US))
}
+ ?: emptyMap()
+
+ private val htmlAttributesDebug = htmlAttributes.entries.joinToString { "${it.key}=${it.value}" }
+ private val htmlInputType = htmlAttributes["type"]
+ private val htmlName = htmlAttributes["name"] ?: ""
+ private val htmlMaxLength = htmlAttributes["maxlength"]?.toIntOrNull()
+ private val isHtmlField = htmlTag == "input"
+ private val isHtmlPasswordField = isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_PASSWORD
+ private val isHtmlTextField = isHtmlField && htmlInputType in HTML_INPUT_FIELD_TYPES_FILLABLE
+
+ // Basic type detection for native fields
+ private val hasPasswordInputType = isPasswordInputType(inputType)
+
+ // HTML fields with non-fillable types (such as submit buttons) should be excluded here
+ private val isAndroidTextField = !isHtmlField && className in ANDROID_TEXT_FIELD_CLASS_NAMES
+ private val isAndroidPasswordField = isAndroidTextField && hasPasswordInputType
+
+ private val isTextField = isAndroidTextField || isHtmlTextField
+
+ // Autofill hint detection for native fields
+ private val autofillHints = node.autofillHints?.filter { isSupportedHint(it) } ?: emptyList()
+ private val excludedByAutofillHints =
+ if (autofillHints.isEmpty()) false else autofillHints.intersect(HINTS_FILLABLE).isEmpty()
+ val hasAutofillHintPassword = autofillHints.intersect(HINTS_PASSWORD).isNotEmpty()
+ private val hasAutofillHintNewPassword = autofillHints.intersect(HINTS_NEW_PASSWORD).isNotEmpty()
+ private val hasAutofillHintUsername = autofillHints.intersect(HINTS_USERNAME).isNotEmpty()
+ private val hasAutofillHintOtp = autofillHints.intersect(HINTS_OTP).isNotEmpty()
+
+ // W3C autocomplete hint detection for HTML fields
+ private val htmlAutocomplete = htmlAttributes["autocomplete"]
+
+ // Ignored for now, see excludedByHints
+ private val excludedByAutocompleteHint = htmlAutocomplete == "off"
+ private val hasAutocompleteHintUsername = htmlAutocomplete == "username"
+ val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password"
+ private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password"
+ private val hasAutocompleteHintPassword = hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword
+ private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code"
+
+ // Results of hint-based field type detection
+ val hasHintUsername = hasAutofillHintUsername || hasAutocompleteHintUsername
+ val hasHintPassword = hasAutofillHintPassword || hasAutocompleteHintPassword
+ val hasHintNewPassword = hasAutofillHintNewPassword || hasAutocompleteHintNewPassword
+ val hasHintOtp = hasAutofillHintOtp || hasAutocompleteHintOtp
+
+ // Basic autofill exclusion checks
+ private val hasAutofillTypeText = node.autofillType == View.AUTOFILL_TYPE_TEXT
+ val isVisible = node.visibility == View.VISIBLE && htmlAttributes["aria-hidden"] != "true"
+
+ // Hidden username fields are used to help password managers save credentials in two-step login
+ // flows.
+ // See:
+ // https://www.chromium.org/developers/design-documents/form-styles-that-chromium-understands
+ val couldBeTwoStepHiddenUsername = !isVisible && isHtmlTextField && hasAutocompleteHintUsername
+
+ // Some websites with two-step login flows offer hidden password fields to fill the password
+ // already in the first step. Thus, we delegate the decision about filling invisible password
+ // fields to the fill rules and only exclude those fields that have incompatible autocomplete
+ // hint.
+ val couldBeTwoStepHiddenPassword =
+ !isVisible && isHtmlPasswordField && (hasAutocompleteHintCurrentPassword || htmlAutocomplete == null)
+
+ // Since many site put autocomplete=off on login forms for compliance reasons or since they are
+ // worried of the user's browser automatically (i.e., without any user interaction) filling
+ // them, which we never do, we choose to ignore the value of excludedByAutocompleteHint.
+ // TODO: Revisit this decision in the future
+ private val excludedByHints = excludedByAutofillHints
+
+ // Only offer to fill into custom views if they explicitly opted into Autofill.
+ val relevantField = hasAutofillTypeText && (isTextField || autofillHints.isNotEmpty()) && !excludedByHints
+
+ // Exclude fields based on hint, resource ID or HTML name.
+ // Note: We still report excluded fields as relevant since they count for adjacency heuristics,
+ // but ensure that they are never detected as password or username fields.
+ private val hasExcludedTerm = EXCLUDED_TERMS.anyMatchesFieldInfo
+ private val notExcluded = relevantField && !hasExcludedTerm
+
+ // Password field heuristics (based only on the current field)
+ private val isPossiblePasswordField =
+ notExcluded && (isAndroidPasswordField || isHtmlPasswordField || hasHintPassword)
+ private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword
+ private val isLikelyPasswordField =
+ isPossiblePasswordField && (isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo)
+ val passwordCertainty =
+ if (isCertainPasswordField) CertaintyLevel.Certain
+ else if (isLikelyPasswordField) CertaintyLevel.Likely
+ else if (isPossiblePasswordField) CertaintyLevel.Possible else CertaintyLevel.Impossible
+
+ // OTP field heuristics (based only on the current field)
+ private val isPossibleOtpField = notExcluded && !isPossiblePasswordField
+ private val isCertainOtpField = isPossibleOtpField && hasHintOtp
+ private val isLikelyOtpField =
+ isPossibleOtpField &&
+ (isCertainOtpField ||
+ OTP_HEURISTIC_TERMS.anyMatchesFieldInfo ||
+ ((htmlMaxLength == null || htmlMaxLength in 6..8) && OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo))
+ val otpCertainty =
+ if (isCertainOtpField) CertaintyLevel.Certain
+ else if (isLikelyOtpField) CertaintyLevel.Likely
+ else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible
+
+ // Username field heuristics (based only on the current field)
+ private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField
+ private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername
+ private val isLikelyUsernameField =
+ isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo))
+ val usernameCertainty =
+ if (isCertainUsernameField) CertaintyLevel.Certain
+ else if (isLikelyUsernameField) CertaintyLevel.Likely
+ else if (isPossibleUsernameField) CertaintyLevel.Possible else CertaintyLevel.Impossible
+
+ infix fun directlyPrecedes(that: FormField?): Boolean {
+ return index == (that ?: return false).index - 1
+ }
+
+ infix fun directlyPrecedes(that: Iterable<FormField>): Boolean {
+ val firstIndex = that.map { it.index }.minOrNull() ?: return false
+ return index == firstIndex - 1
+ }
+
+ infix fun directlyFollows(that: FormField?): Boolean {
+ return index == (that ?: return false).index + 1
+ }
+
+ infix fun directlyFollows(that: Iterable<FormField>): Boolean {
+ val lastIndex = that.map { it.index }.maxOrNull() ?: return false
+ return index == lastIndex + 1
+ }
+
+ override fun toString(): String {
+ val field = if (isHtmlTextField) "$htmlTag[type=$htmlInputType]" else className
+ val description =
+ "\"$hint\", \"$fieldId\"${if (isFocused) ", focused" else ""}${if (isVisible) ", visible" else ""}, $webOrigin, $htmlAttributesDebug, $autofillHints"
+ return "$field ($description): password=$passwordCertainty, username=$usernameCertainty, otp=$otpCertainty"
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other == null) return false
+ if (this.javaClass != other.javaClass) return false
+ return autofillId == (other as FormField).autofillId
+ }
+
+ override fun hashCode(): Int {
+ return autofillId.hashCode()
+ }
}
diff --git a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt
index f7f24ef6..8c95ee90 100644
--- a/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt
+++ b/autofill-parser/src/main/java/com/github/androidpasswordstore/autofillparser/PublicSuffixListCache.kt
@@ -11,21 +11,20 @@ import mozilla.components.lib.publicsuffixlist.PublicSuffixList
private object PublicSuffixListCache {
- private lateinit var publicSuffixList: PublicSuffixList
+ private lateinit var publicSuffixList: PublicSuffixList
- fun getOrCachePublicSuffixList(context: Context): PublicSuffixList {
- if (!::publicSuffixList.isInitialized) {
- publicSuffixList = PublicSuffixList(context)
- // Trigger loading the actual public suffix list, but don't block.
- @Suppress("DeferredResultUnused")
- publicSuffixList.prefetch()
- }
- return publicSuffixList
+ fun getOrCachePublicSuffixList(context: Context): PublicSuffixList {
+ if (!::publicSuffixList.isInitialized) {
+ publicSuffixList = PublicSuffixList(context)
+ // Trigger loading the actual public suffix list, but don't block.
+ @Suppress("DeferredResultUnused") publicSuffixList.prefetch()
}
+ return publicSuffixList
+ }
}
public fun cachePublicSuffixList(context: Context) {
- PublicSuffixListCache.getOrCachePublicSuffixList(context)
+ PublicSuffixListCache.getOrCachePublicSuffixList(context)
}
/**
@@ -36,17 +35,15 @@ public fun cachePublicSuffixList(context: Context) {
* the return value for valid domains.
*/
internal fun getPublicSuffixPlusOne(context: Context, domain: String, customSuffixes: Sequence<String>) = runBlocking {
- // We only feed valid domain names which are not IP addresses into getPublicSuffixPlusOne.
- // We do not check whether the domain actually exists (actually, not even whether its TLD
- // exists). As long as we restrict ourselves to syntactically valid domain names,
- // getPublicSuffixPlusOne will return non-colliding results.
- if (!Patterns.DOMAIN_NAME.matcher(domain).matches() || Patterns.IP_ADDRESS.matcher(domain)
- .matches()
- ) {
- domain
- } else {
- getCanonicalSuffix(context, domain, customSuffixes)
- }
+ // We only feed valid domain names which are not IP addresses into getPublicSuffixPlusOne.
+ // We do not check whether the domain actually exists (actually, not even whether its TLD
+ // exists). As long as we restrict ourselves to syntactically valid domain names,
+ // getPublicSuffixPlusOne will return non-colliding results.
+ if (!Patterns.DOMAIN_NAME.matcher(domain).matches() || Patterns.IP_ADDRESS.matcher(domain).matches()) {
+ domain
+ } else {
+ getCanonicalSuffix(context, domain, customSuffixes)
+ }
}
/**
@@ -56,26 +53,21 @@ internal fun getPublicSuffixPlusOne(context: Context, domain: String, customSuff
* - the direct subdomain of [suffix] of which [domain] is a subdomain.
*/
private fun getSuffixPlusUpToOne(domain: String, suffix: String): String? {
- if (domain == suffix)
- return domain
- val prefix = domain.removeSuffix(".$suffix")
- if (prefix == domain || prefix.isEmpty())
- return null
- val lastPrefixPart = prefix.takeLastWhile { it != '.' }
- return "$lastPrefixPart.$suffix"
+ if (domain == suffix) return domain
+ val prefix = domain.removeSuffix(".$suffix")
+ if (prefix == domain || prefix.isEmpty()) return null
+ val lastPrefixPart = prefix.takeLastWhile { it != '.' }
+ return "$lastPrefixPart.$suffix"
}
-private suspend fun getCanonicalSuffix(
- context: Context, domain: String, customSuffixes: Sequence<String>): String {
- val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context)
- val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await()
- ?: return domain
- var longestSuffix = publicSuffixPlusOne
- for (customSuffix in customSuffixes) {
- val suffixPlusUpToOne = getSuffixPlusUpToOne(domain, customSuffix) ?: continue
- // A shorter suffix is automatically a substring.
- if (suffixPlusUpToOne.length > longestSuffix.length)
- longestSuffix = suffixPlusUpToOne
- }
- return longestSuffix
+private suspend fun getCanonicalSuffix(context: Context, domain: String, customSuffixes: Sequence<String>): String {
+ val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context)
+ val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await() ?: return domain
+ var longestSuffix = publicSuffixPlusOne
+ for (customSuffix in customSuffixes) {
+ val suffixPlusUpToOne = getSuffixPlusUpToOne(domain, customSuffix) ?: continue
+ // A shorter suffix is automatically a substring.
+ if (suffixPlusUpToOne.length > longestSuffix.length) longestSuffix = suffixPlusUpToOne
+ }
+ return longestSuffix
}
diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt
index f50d0d5a..c31df752 100644
--- a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt
+++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixList.kt
@@ -22,52 +22,48 @@ import kotlinx.coroutines.async
/**
* API for reading and accessing the public suffix list.
*
- * > A "public suffix" is one under which Internet users can (or historically could) directly register names. Some
- * > examples of public suffixes are .com, .co.uk and pvt.k12.ma.us. The Public Suffix List is a list of all known
- * > public suffixes.
+ * > A "public suffix" is one under which Internet users can (or historically could) directly
+ * register names. Some > examples of public suffixes are .com, .co.uk and pvt.k12.ma.us. The Public
+ * Suffix List is a list of all known > public suffixes.
*
- * Note that this implementation applies the rules of the public suffix list only and does not validate domains.
+ * Note that this implementation applies the rules of the public suffix list only and does not
+ * validate domains.
*
- * https://publicsuffix.org/
- * https://github.com/publicsuffix/list
+ * https://publicsuffix.org/ https://github.com/publicsuffix/list
*/
internal class PublicSuffixList(
- context: Context,
- dispatcher: CoroutineDispatcher = Dispatchers.IO,
- private val scope: CoroutineScope = CoroutineScope(dispatcher)
+ context: Context,
+ dispatcher: CoroutineDispatcher = Dispatchers.IO,
+ private val scope: CoroutineScope = CoroutineScope(dispatcher)
) {
- private val data: PublicSuffixListData by lazy(LazyThreadSafetyMode.PUBLICATION) { PublicSuffixListLoader.load(context) }
+ private val data: PublicSuffixListData by lazy(LazyThreadSafetyMode.PUBLICATION) {
+ PublicSuffixListLoader.load(context)
+ }
- /**
- * Prefetch the public suffix list from disk so that it is available in memory.
- */
- fun prefetch(): Deferred<Unit> = scope.async {
- data.run { Unit }
- }
+ /** Prefetch the public suffix list from disk so that it is available in memory. */
+ fun prefetch(): Deferred<Unit> = scope.async { data.run { Unit } }
- /**
- * Returns the public suffix and one more level; known as the registrable domain. Returns `null` if
- * [domain] is a public suffix itself.
- *
- * E.g.:
- * ```
- * wwww.mozilla.org -> mozilla.org
- * www.bcc.co.uk -> bbc.co.uk
- * a.b.ide.kyoto.jp -> b.ide.kyoto.jp
- * ```
- *
- * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any unexpected values
- * are passed (e.g., a full URL, a domain with a trailing '/', etc) this may return an incorrect result.
- */
- fun getPublicSuffixPlusOne(domain: String): Deferred<String?> = scope.async {
- when (val offset = data.getPublicSuffixOffset(domain)) {
- is PublicSuffixOffset.Offset -> domain
- .split('.')
- .drop(offset.value)
- .joinToString(separator = ".")
- else -> null
- }
+ /**
+ * Returns the public suffix and one more level; known as the registrable domain. Returns `null`
+ * if [domain] is a public suffix itself.
+ *
+ * E.g.:
+ * ```
+ * wwww.mozilla.org -> mozilla.org
+ * www.bcc.co.uk -> bbc.co.uk
+ * a.b.ide.kyoto.jp -> b.ide.kyoto.jp
+ * ```
+ *
+ * @param [domain] _must_ be a valid domain. [PublicSuffixList] performs no validation, and if any
+ * unexpected values are passed (e.g., a full URL, a domain with a trailing '/', etc) this may
+ * return an incorrect result.
+ */
+ fun getPublicSuffixPlusOne(domain: String): Deferred<String?> =
+ scope.async {
+ when (val offset = data.getPublicSuffixOffset(domain)) {
+ is PublicSuffixOffset.Offset -> domain.split('.').drop(offset.value).joinToString(separator = ".")
+ else -> null
+ }
}
-
}
diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt
index 47816ea0..eb59f3d3 100644
--- a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt
+++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListData.kt
@@ -15,154 +15,148 @@ package mozilla.components.lib.publicsuffixlist
import java.net.IDN
import mozilla.components.lib.publicsuffixlist.ext.binarySearch
-/**
- * Class wrapping the public suffix list data and offering methods for accessing rules in it.
- */
-internal class PublicSuffixListData(
- private val rules: ByteArray,
- private val exceptions: ByteArray
-) {
-
- private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? {
- return rules.binarySearch(labels, labelIndex)
- }
+/** Class wrapping the public suffix list data and offering methods for accessing rules in it. */
+internal class PublicSuffixListData(private val rules: ByteArray, private val exceptions: ByteArray) {
- private fun binarySearchExceptions(labels: List<ByteArray>, labelIndex: Int): String? {
- return exceptions.binarySearch(labels, labelIndex)
- }
+ private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? {
+ return rules.binarySearch(labels, labelIndex)
+ }
- @Suppress("ReturnCount")
- fun getPublicSuffixOffset(domain: String): PublicSuffixOffset? {
- if (domain.isEmpty()) {
- return null
- }
+ private fun binarySearchExceptions(labels: List<ByteArray>, labelIndex: Int): String? {
+ return exceptions.binarySearch(labels, labelIndex)
+ }
- val domainLabels = IDN.toUnicode(domain).split('.')
- if (domainLabels.find { it.isEmpty() } != null) {
- // At least one of the labels is empty: Bail out.
- return null
- }
+ @Suppress("ReturnCount")
+ fun getPublicSuffixOffset(domain: String): PublicSuffixOffset? {
+ if (domain.isEmpty()) {
+ return null
+ }
- val rule = findMatchingRule(domainLabels)
+ val domainLabels = IDN.toUnicode(domain).split('.')
+ if (domainLabels.find { it.isEmpty() } != null) {
+ // At least one of the labels is empty: Bail out.
+ return null
+ }
- if (domainLabels.size == rule.size && rule[0][0] != PublicSuffixListData.EXCEPTION_MARKER) {
- // The domain is a public suffix.
- return if (rule == PublicSuffixListData.PREVAILING_RULE) {
- PublicSuffixOffset.PrevailingRule
- } else {
- PublicSuffixOffset.PublicSuffix
- }
- }
+ val rule = findMatchingRule(domainLabels)
- return if (rule[0][0] == PublicSuffixListData.EXCEPTION_MARKER) {
- // Exception rules hold the effective TLD plus one.
- PublicSuffixOffset.Offset(domainLabels.size - rule.size)
- } else {
- // Otherwise the rule is for a public suffix, so we must take one more label.
- PublicSuffixOffset.Offset(domainLabels.size - (rule.size + 1))
- }
+ if (domainLabels.size == rule.size && rule[0][0] != PublicSuffixListData.EXCEPTION_MARKER) {
+ // The domain is a public suffix.
+ return if (rule == PublicSuffixListData.PREVAILING_RULE) {
+ PublicSuffixOffset.PrevailingRule
+ } else {
+ PublicSuffixOffset.PublicSuffix
+ }
}
- /**
- * Find a matching rule for the given domain labels.
- *
- * This algorithm is based on OkHttp's PublicSuffixDatabase class:
- * https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/internal/publicsuffix/PublicSuffixDatabase.java
- */
- private fun findMatchingRule(domainLabels: List<String>): List<String> {
- // Break apart the domain into UTF-8 labels, i.e. foo.bar.com turns into [foo, bar, com].
- val domainLabelsBytes = domainLabels.map { it.toByteArray(Charsets.UTF_8) }
-
- val exactMatch = findExactMatch(domainLabelsBytes)
- val wildcardMatch = findWildcardMatch(domainLabelsBytes)
- val exceptionMatch = findExceptionMatch(domainLabelsBytes, wildcardMatch)
-
- if (exceptionMatch != null) {
- return ("${PublicSuffixListData.EXCEPTION_MARKER}$exceptionMatch").split('.')
- }
+ return if (rule[0][0] == PublicSuffixListData.EXCEPTION_MARKER) {
+ // Exception rules hold the effective TLD plus one.
+ PublicSuffixOffset.Offset(domainLabels.size - rule.size)
+ } else {
+ // Otherwise the rule is for a public suffix, so we must take one more label.
+ PublicSuffixOffset.Offset(domainLabels.size - (rule.size + 1))
+ }
+ }
+
+ /**
+ * Find a matching rule for the given domain labels.
+ *
+ * This algorithm is based on OkHttp's PublicSuffixDatabase class:
+ * https://github.com/square/okhttp/blob/master/okhttp/src/main/java/okhttp3/internal/publicsuffix/PublicSuffixDatabase.java
+ */
+ private fun findMatchingRule(domainLabels: List<String>): List<String> {
+ // Break apart the domain into UTF-8 labels, i.e. foo.bar.com turns into [foo, bar, com].
+ val domainLabelsBytes = domainLabels.map { it.toByteArray(Charsets.UTF_8) }
+
+ val exactMatch = findExactMatch(domainLabelsBytes)
+ val wildcardMatch = findWildcardMatch(domainLabelsBytes)
+ val exceptionMatch = findExceptionMatch(domainLabelsBytes, wildcardMatch)
+
+ if (exceptionMatch != null) {
+ return ("${PublicSuffixListData.EXCEPTION_MARKER}$exceptionMatch").split('.')
+ }
- if (exactMatch == null && wildcardMatch == null) {
- return PublicSuffixListData.PREVAILING_RULE
- }
+ if (exactMatch == null && wildcardMatch == null) {
+ return PublicSuffixListData.PREVAILING_RULE
+ }
- val exactRuleLabels = exactMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
- val wildcardRuleLabels = wildcardMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
+ val exactRuleLabels = exactMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
+ val wildcardRuleLabels = wildcardMatch?.split('.') ?: PublicSuffixListData.EMPTY_RULE
- return if (exactRuleLabels.size > wildcardRuleLabels.size) {
- exactRuleLabels
- } else {
- wildcardRuleLabels
- }
+ return if (exactRuleLabels.size > wildcardRuleLabels.size) {
+ exactRuleLabels
+ } else {
+ wildcardRuleLabels
}
+ }
- /**
- * Returns an exact match or null.
- */
- private fun findExactMatch(labels: List<ByteArray>): String? {
- // Start by looking for exact matches. We start at the leftmost label. For example, foo.bar.com
- // will look like: [foo, bar, com], [bar, com], [com]. The longest matching rule wins.
-
- for (i in 0 until labels.size) {
- val rule = binarySearchRules(labels, i)
+ /** Returns an exact match or null. */
+ private fun findExactMatch(labels: List<ByteArray>): String? {
+ // Start by looking for exact matches. We start at the leftmost label. For example,
+ // foo.bar.com
+ // will look like: [foo, bar, com], [bar, com], [com]. The longest matching rule wins.
- if (rule != null) {
- return rule
- }
- }
+ for (i in 0 until labels.size) {
+ val rule = binarySearchRules(labels, i)
- return null
+ if (rule != null) {
+ return rule
+ }
}
- /**
- * Returns a wildcard match or null.
- */
- private fun findWildcardMatch(labels: List<ByteArray>): String? {
- // In theory, wildcard rules are not restricted to having the wildcard in the leftmost position.
- // In practice, wildcards are always in the leftmost position. For now, this implementation
- // cheats and does not attempt every possible permutation. Instead, it only considers wildcards
- // in the leftmost position. We assert this fact when we generate the public suffix file. If
- // this assertion ever fails we'll need to refactor this implementation.
- if (labels.size > 1) {
- val labelsWithWildcard = labels.toMutableList()
- for (labelIndex in 0 until labelsWithWildcard.size) {
- labelsWithWildcard[labelIndex] = PublicSuffixListData.WILDCARD_LABEL
- val rule = binarySearchRules(labelsWithWildcard, labelIndex)
- if (rule != null) {
- return rule
- }
- }
+ return null
+ }
+
+ /** Returns a wildcard match or null. */
+ private fun findWildcardMatch(labels: List<ByteArray>): String? {
+ // In theory, wildcard rules are not restricted to having the wildcard in the leftmost
+ // position.
+ // In practice, wildcards are always in the leftmost position. For now, this implementation
+ // cheats and does not attempt every possible permutation. Instead, it only considers
+ // wildcards
+ // in the leftmost position. We assert this fact when we generate the public suffix file. If
+ // this assertion ever fails we'll need to refactor this implementation.
+ if (labels.size > 1) {
+ val labelsWithWildcard = labels.toMutableList()
+ for (labelIndex in 0 until labelsWithWildcard.size) {
+ labelsWithWildcard[labelIndex] = PublicSuffixListData.WILDCARD_LABEL
+ val rule = binarySearchRules(labelsWithWildcard, labelIndex)
+ if (rule != null) {
+ return rule
}
-
- return null
+ }
}
- private fun findExceptionMatch(labels: List<ByteArray>, wildcardMatch: String?): String? {
- // Exception rules only apply to wildcard rules, so only try it if we matched a wildcard.
- if (wildcardMatch == null) {
- return null
- }
+ return null
+ }
- for (labelIndex in 0 until labels.size) {
- val rule = binarySearchExceptions(labels, labelIndex)
- if (rule != null) {
- return rule
- }
- }
+ private fun findExceptionMatch(labels: List<ByteArray>, wildcardMatch: String?): String? {
+ // Exception rules only apply to wildcard rules, so only try it if we matched a wildcard.
+ if (wildcardMatch == null) {
+ return null
+ }
- return null
+ for (labelIndex in 0 until labels.size) {
+ val rule = binarySearchExceptions(labels, labelIndex)
+ if (rule != null) {
+ return rule
+ }
}
- companion object {
+ return null
+ }
- val WILDCARD_LABEL = byteArrayOf('*'.toByte())
- val PREVAILING_RULE = listOf("*")
- val EMPTY_RULE = listOf<String>()
- const val EXCEPTION_MARKER = '!'
- }
+ companion object {
+
+ val WILDCARD_LABEL = byteArrayOf('*'.toByte())
+ val PREVAILING_RULE = listOf("*")
+ val EMPTY_RULE = listOf<String>()
+ const val EXCEPTION_MARKER = '!'
+ }
}
internal sealed class PublicSuffixOffset {
- data class Offset(val value: Int) : PublicSuffixOffset()
- object PublicSuffix : PublicSuffixOffset()
- object PrevailingRule : PublicSuffixOffset()
+ data class Offset(val value: Int) : PublicSuffixOffset()
+ object PublicSuffix : PublicSuffixOffset()
+ object PrevailingRule : PublicSuffixOffset()
}
diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt
index 785ee342..9fede799 100644
--- a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt
+++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/PublicSuffixListLoader.kt
@@ -20,38 +20,34 @@ private const val PUBLIC_SUFFIX_LIST_FILE = "publicsuffixes"
internal object PublicSuffixListLoader {
- fun load(context: Context): PublicSuffixListData = context.assets.open(
- PUBLIC_SUFFIX_LIST_FILE
- ).buffered().use { stream ->
- val publicSuffixSize = stream.readInt()
- val publicSuffixBytes = stream.readFully(publicSuffixSize)
+ fun load(context: Context): PublicSuffixListData =
+ context.assets.open(PUBLIC_SUFFIX_LIST_FILE).buffered().use { stream ->
+ val publicSuffixSize = stream.readInt()
+ val publicSuffixBytes = stream.readFully(publicSuffixSize)
- val exceptionSize = stream.readInt()
- val exceptionBytes = stream.readFully(exceptionSize)
+ val exceptionSize = stream.readInt()
+ val exceptionBytes = stream.readFully(exceptionSize)
- PublicSuffixListData(publicSuffixBytes, exceptionBytes)
+ PublicSuffixListData(publicSuffixBytes, exceptionBytes)
}
}
@Suppress("MagicNumber")
private fun BufferedInputStream.readInt(): Int {
- return (read() and 0xff shl 24
- or (read() and 0xff shl 16)
- or (read() and 0xff shl 8)
- or (read() and 0xff))
+ return (read() and 0xff shl 24 or (read() and 0xff shl 16) or (read() and 0xff shl 8) or (read() and 0xff))
}
private fun BufferedInputStream.readFully(size: Int): ByteArray {
- val bytes = ByteArray(size)
-
- var offset = 0
- while (offset < size) {
- val read = read(bytes, offset, size - offset)
- if (read == -1) {
- throw IOException("Unexpected end of stream")
- }
- offset += read
+ val bytes = ByteArray(size)
+
+ var offset = 0
+ while (offset < size) {
+ val read = read(bytes, offset, size - offset)
+ if (read == -1) {
+ throw IOException("Unexpected end of stream")
}
+ offset += read
+ }
- return bytes
+ return bytes
}
diff --git a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt
index 735cf21d..8a8f3e94 100644
--- a/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt
+++ b/autofill-parser/src/main/java/mozilla/components/lib/publicsuffixlist/ext/ByteArray.kt
@@ -24,107 +24,106 @@ private const val BITMASK = 0xff.toByte()
*/
@Suppress("ComplexMethod", "NestedBlockDepth")
internal fun ByteArray.binarySearch(labels: List<ByteArray>, labelIndex: Int): String? {
- var low = 0
- var high = size
- var match: String? = null
-
- while (low < high) {
- val mid = (low + high) / 2
- val start = findStartOfLineFromIndex(mid)
- val end = findEndOfLineFromIndex(start)
-
- val publicSuffixLength = start + end - start
-
- var compareResult: Int
- var currentLabelIndex = labelIndex
- var currentLabelByteIndex = 0
- var publicSuffixByteIndex = 0
-
- var expectDot = false
- while (true) {
- val byte0 = if (expectDot) {
- expectDot = false
- '.'.toByte()
- } else {
- labels[currentLabelIndex][currentLabelByteIndex] and BITMASK
- }
-
- val byte1 = this[start + publicSuffixByteIndex] and BITMASK
-
- // Compare the bytes. Note that the file stores UTF-8 encoded bytes, so we must compare the
- // unsigned bytes.
- @Suppress("EXPERIMENTAL_API_USAGE")
- compareResult = (byte0.toUByte() - byte1.toUByte()).toInt()
- if (compareResult != 0) {
- break
- }
-
- publicSuffixByteIndex++
- currentLabelByteIndex++
-
- if (publicSuffixByteIndex == publicSuffixLength) {
- break
- }
-
- if (labels[currentLabelIndex].size == currentLabelByteIndex) {
- // We've exhausted our current label. Either there are more labels to compare, in which
- // case we expect a dot as the next character. Otherwise, we've checked all our labels.
- if (currentLabelIndex == labels.size - 1) {
- break
- } else {
- currentLabelIndex++
- currentLabelByteIndex = -1
- expectDot = true
- }
- }
+ var low = 0
+ var high = size
+ var match: String? = null
+
+ while (low < high) {
+ val mid = (low + high) / 2
+ val start = findStartOfLineFromIndex(mid)
+ val end = findEndOfLineFromIndex(start)
+
+ val publicSuffixLength = start + end - start
+
+ var compareResult: Int
+ var currentLabelIndex = labelIndex
+ var currentLabelByteIndex = 0
+ var publicSuffixByteIndex = 0
+
+ var expectDot = false
+ while (true) {
+ val byte0 =
+ if (expectDot) {
+ expectDot = false
+ '.'.toByte()
+ } else {
+ labels[currentLabelIndex][currentLabelByteIndex] and BITMASK
}
- if (compareResult < 0) {
- high = start - 1
- } else if (compareResult > 0) {
- low = start + end + 1
+ val byte1 = this[start + publicSuffixByteIndex] and BITMASK
+
+ // Compare the bytes. Note that the file stores UTF-8 encoded bytes, so we must compare
+ // the
+ // unsigned bytes.
+ @Suppress("EXPERIMENTAL_API_USAGE") compareResult = (byte0.toUByte() - byte1.toUByte()).toInt()
+ if (compareResult != 0) {
+ break
+ }
+
+ publicSuffixByteIndex++
+ currentLabelByteIndex++
+
+ if (publicSuffixByteIndex == publicSuffixLength) {
+ break
+ }
+
+ if (labels[currentLabelIndex].size == currentLabelByteIndex) {
+ // We've exhausted our current label. Either there are more labels to compare, in
+ // which
+ // case we expect a dot as the next character. Otherwise, we've checked all our
+ // labels.
+ if (currentLabelIndex == labels.size - 1) {
+ break
} else {
- // We found a match, but are the lengths equal?
- val publicSuffixBytesLeft = publicSuffixLength - publicSuffixByteIndex
- var labelBytesLeft = labels[currentLabelIndex].size - currentLabelByteIndex
- for (i in currentLabelIndex + 1 until labels.size) {
- labelBytesLeft += labels[i].size
- }
-
- if (labelBytesLeft < publicSuffixBytesLeft) {
- high = start - 1
- } else if (labelBytesLeft > publicSuffixBytesLeft) {
- low = start + end + 1
- } else {
- // Found a match.
- match = String(this, start, publicSuffixLength, Charsets.UTF_8)
- break
- }
+ currentLabelIndex++
+ currentLabelByteIndex = -1
+ expectDot = true
}
+ }
+ }
+
+ if (compareResult < 0) {
+ high = start - 1
+ } else if (compareResult > 0) {
+ low = start + end + 1
+ } else {
+ // We found a match, but are the lengths equal?
+ val publicSuffixBytesLeft = publicSuffixLength - publicSuffixByteIndex
+ var labelBytesLeft = labels[currentLabelIndex].size - currentLabelByteIndex
+ for (i in currentLabelIndex + 1 until labels.size) {
+ labelBytesLeft += labels[i].size
+ }
+
+ if (labelBytesLeft < publicSuffixBytesLeft) {
+ high = start - 1
+ } else if (labelBytesLeft > publicSuffixBytesLeft) {
+ low = start + end + 1
+ } else {
+ // Found a match.
+ match = String(this, start, publicSuffixLength, Charsets.UTF_8)
+ break
+ }
}
+ }
- return match
+ return match
}
-/**
- * Search for a '\n' that marks the start of a value. Don't go back past the start of the array.
- */
+/** Search for a '\n' that marks the start of a value. Don't go back past the start of the array. */
private fun ByteArray.findStartOfLineFromIndex(start: Int): Int {
- var index = start
- while (index > -1 && this[index] != '\n'.toByte()) {
- index--
- }
- index++
- return index
+ var index = start
+ while (index > -1 && this[index] != '\n'.toByte()) {
+ index--
+ }
+ index++
+ return index
}
-/**
- * Search for a '\n' that marks the end of a value.
- */
+/** Search for a '\n' that marks the end of a value. */
private fun ByteArray.findEndOfLineFromIndex(start: Int): Int {
- var end = 1
- while (this[start + end] != '\n'.toByte()) {
- end++
- }
- return end
+ var end = 1
+ while (this[start + end] != '\n'.toByte()) {
+ end++
+ }
+ return end
}
diff --git a/build.gradle.kts b/build.gradle.kts
index 92b8ab8d..1ab2b205 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -3,25 +3,19 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
plugins {
- `binary-compatibility-validator`
- `aps-plugin`
+ `binary-compatibility-validator`
+ `aps-plugin`
}
-buildscript {
- dependencies {
- classpath(Plugins.ktfmtGradlePlugin)
- }
-}
+buildscript { dependencies { classpath(Plugins.ktfmtGradlePlugin) } }
-allprojects {
- apply(plugin = "com.ncorti.ktfmt.gradle")
-}
+allprojects { apply(plugin = "com.ncorti.ktfmt.gradle") }
subprojects {
- configurations.all {
- resolutionStrategy.dependencySubstitution {
- substitute(module("org.jetbrains.trove4j:trove4j:20160824"))
- .using(module("org.jetbrains.intellij.deps:trove4j:1.0.20200330"))
- }
+ configurations.all {
+ resolutionStrategy.dependencySubstitution {
+ substitute(module("org.jetbrains.trove4j:trove4j:20160824"))
+ .using(module("org.jetbrains.intellij.deps:trove4j:1.0.20200330"))
}
+ }
}
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
index 87f70c66..627cab92 100644
--- a/buildSrc/build.gradle.kts
+++ b/buildSrc/build.gradle.kts
@@ -3,46 +3,42 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
-plugins {
- `kotlin-dsl`
-}
+plugins { `kotlin-dsl` }
repositories {
- google()
- gradlePluginPortal()
- mavenCentral()
- // For binary compatibility validator.
- maven { url = uri("https://kotlin.bintray.com/kotlinx") }
+ google()
+ gradlePluginPortal()
+ mavenCentral()
+ // For binary compatibility validator.
+ maven { url = uri("https://kotlin.bintray.com/kotlinx") }
}
-kotlinDslPluginOptions {
- experimentalWarning.set(false)
-}
+kotlinDslPluginOptions { experimentalWarning.set(false) }
gradlePlugin {
- plugins {
- register("aps") {
- id = "aps-plugin"
- implementationClass = "PasswordStorePlugin"
- }
- register("crowdin") {
- id = "crowdin-plugin"
- implementationClass = "CrowdinDownloadPlugin"
- }
- register("versioning") {
- id = "versioning-plugin"
- implementationClass = "VersioningPlugin"
- }
+ plugins {
+ register("aps") {
+ id = "aps-plugin"
+ implementationClass = "PasswordStorePlugin"
+ }
+ register("crowdin") {
+ id = "crowdin-plugin"
+ implementationClass = "CrowdinDownloadPlugin"
+ }
+ register("versioning") {
+ id = "versioning-plugin"
+ implementationClass = "VersioningPlugin"
}
+ }
}
dependencies {
- implementation(Plugins.androidGradlePlugin)
- implementation(Plugins.binaryCompatibilityValidator)
- implementation(Plugins.dokkaPlugin)
- implementation(Plugins.downloadTaskPlugin)
- implementation(Plugins.kotlinGradlePlugin)
- implementation(Plugins.ktfmtGradlePlugin)
- implementation(Plugins.mavenPublishPlugin)
- implementation(Plugins.semver4j)
+ implementation(Plugins.androidGradlePlugin)
+ implementation(Plugins.binaryCompatibilityValidator)
+ implementation(Plugins.dokkaPlugin)
+ implementation(Plugins.downloadTaskPlugin)
+ implementation(Plugins.kotlinGradlePlugin)
+ implementation(Plugins.ktfmtGradlePlugin)
+ implementation(Plugins.mavenPublishPlugin)
+ implementation(Plugins.semver4j)
}
diff --git a/buildSrc/buildSrc/build.gradle.kts b/buildSrc/buildSrc/build.gradle.kts
index 7092e5dd..f4bc8a7e 100644
--- a/buildSrc/buildSrc/build.gradle.kts
+++ b/buildSrc/buildSrc/build.gradle.kts
@@ -1,6 +1,4 @@
-plugins {
- `kotlin-dsl`
-}
+plugins { `kotlin-dsl` }
repositories {
mavenCentral()
@@ -8,9 +6,7 @@ repositories {
gradlePluginPortal()
}
-kotlinDslPluginOptions {
- experimentalWarning.set(false)
-}
+kotlinDslPluginOptions { experimentalWarning.set(false) }
// force compilation of Dependencies.kt so it can be referenced in buildSrc/build.gradle.kts
sourceSets.main {
diff --git a/buildSrc/src/main/java/BaseProjectConfig.kt b/buildSrc/src/main/java/BaseProjectConfig.kt
index 6593fe39..5dcde1af 100644
--- a/buildSrc/src/main/java/BaseProjectConfig.kt
+++ b/buildSrc/src/main/java/BaseProjectConfig.kt
@@ -17,125 +17,114 @@ import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
/**
- * Configure root project.
- * Note that classpath dependencies still need to be defined in the `buildscript` block in the top-level build.gradle.kts file.
+ * Configure root project. Note that classpath dependencies still need to be defined in the
+ * `buildscript` block in the top-level build.gradle.kts file.
*/
internal fun Project.configureForRootProject() {
- // register task for cleaning the build directory in the root project
- tasks.register("clean", Delete::class.java) {
- delete(rootProject.buildDir)
- }
- tasks.withType<Wrapper> {
- gradleVersion = "6.8.3"
- distributionType = Wrapper.DistributionType.ALL
- distributionSha256Sum = "9af5c8e7e2cd1a3b0f694a4ac262b9f38c75262e74a9e8b5101af302a6beadd7"
- }
- configureBinaryCompatibilityValidator()
+ // register task for cleaning the build directory in the root project
+ tasks.register("clean", Delete::class.java) { delete(rootProject.buildDir) }
+ tasks.withType<Wrapper> {
+ gradleVersion = "6.8.3"
+ distributionType = Wrapper.DistributionType.ALL
+ distributionSha256Sum = "9af5c8e7e2cd1a3b0f694a4ac262b9f38c75262e74a9e8b5101af302a6beadd7"
+ }
+ configureBinaryCompatibilityValidator()
}
-/**
- * Configure all projects including the root project
- */
+/** Configure all projects including the root project */
internal fun Project.configureForAllProjects() {
- repositories {
- google()
- mavenCentral()
- jcenter() {
- content {
- // https://github.com/zhanghai/AndroidFastScroll/issues/35
- includeModule("me.zhanghai.android.fastscroll", "library")
- // https://github.com/open-keychain/open-keychain/issues/2645
- includeModule("org.sufficientlysecure", "sshauthentication-api")
- }
- }
- maven("https://jitpack.io") {
- name = "Jitpack"
- content {
- includeModule("com.github.android-password-store", "zxing-android-embedded")
- includeModule("com.github.haroldadmin", "WhatTheStack")
- }
- }
+ repositories {
+ google()
+ mavenCentral()
+ jcenter() {
+ content {
+ // https://github.com/zhanghai/AndroidFastScroll/issues/35
+ includeModule("me.zhanghai.android.fastscroll", "library")
+ // https://github.com/open-keychain/open-keychain/issues/2645
+ includeModule("org.sufficientlysecure", "sshauthentication-api")
+ }
}
- tasks.withType<KotlinCompile> {
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_1_8.toString()
- freeCompilerArgs = freeCompilerArgs + additionalCompilerArgs
- languageVersion = "1.4"
- useIR = true
- }
+ maven("https://jitpack.io") {
+ name = "Jitpack"
+ content {
+ includeModule("com.github.android-password-store", "zxing-android-embedded")
+ includeModule("com.github.haroldadmin", "WhatTheStack")
+ }
}
- tasks.withType<Test> {
- maxParallelForks = Runtime.getRuntime().availableProcessors() * 2
- testLogging {
- events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
- }
+ }
+ tasks.withType<KotlinCompile> {
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_1_8.toString()
+ freeCompilerArgs = freeCompilerArgs + additionalCompilerArgs
+ languageVersion = "1.4"
+ useIR = true
}
+ }
+ tasks.withType<Test> {
+ maxParallelForks = Runtime.getRuntime().availableProcessors() * 2
+ testLogging { events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED) }
+ }
}
-/**
- * Checks if we're building a snapshot
- */
+/** Checks if we're building a snapshot */
@Suppress("UnstableApiUsage")
fun Project.isSnapshot(): Boolean {
- with(project.providers) {
- val workflow = environmentVariable("GITHUB_WORKFLOW").forUseAtConfigurationTime()
- val snapshot = environmentVariable("SNAPSHOT").forUseAtConfigurationTime()
- return workflow.isPresent && snapshot.isPresent
- }
+ with(project.providers) {
+ val workflow = environmentVariable("GITHUB_WORKFLOW").forUseAtConfigurationTime()
+ val snapshot = environmentVariable("SNAPSHOT").forUseAtConfigurationTime()
+ return workflow.isPresent && snapshot.isPresent
+ }
}
-/**
- * Apply configurations for app module
- */
+/** Apply configurations for app module */
@Suppress("UnstableApiUsage")
internal fun BaseAppModuleExtension.configureAndroidApplicationOptions(project: Project) {
- val minifySwitch = project.providers.environmentVariable("DISABLE_MINIFY").forUseAtConfigurationTime()
+ val minifySwitch =
+ project.providers.environmentVariable("DISABLE_MINIFY").forUseAtConfigurationTime()
- adbOptions.installOptions("--user 0")
+ adbOptions.installOptions("--user 0")
- buildFeatures {
- viewBinding = true
- buildConfig = true
- }
+ buildFeatures {
+ viewBinding = true
+ buildConfig = true
+ }
- buildTypes {
- named("release") {
- isMinifyEnabled = !minifySwitch.isPresent
- setProguardFiles(listOf("proguard-android-optimize.txt", "proguard-rules.pro"))
- buildConfigField("boolean", "ENABLE_DEBUG_FEATURES", "${project.isSnapshot()}")
- }
- named("debug") {
- applicationIdSuffix = ".debug"
- versionNameSuffix = "-debug"
- isMinifyEnabled = false
- buildConfigField("boolean", "ENABLE_DEBUG_FEATURES", "true")
- }
+ buildTypes {
+ named("release") {
+ isMinifyEnabled = !minifySwitch.isPresent
+ setProguardFiles(listOf("proguard-android-optimize.txt", "proguard-rules.pro"))
+ buildConfigField("boolean", "ENABLE_DEBUG_FEATURES", "${project.isSnapshot()}")
}
+ named("debug") {
+ applicationIdSuffix = ".debug"
+ versionNameSuffix = "-debug"
+ isMinifyEnabled = false
+ buildConfigField("boolean", "ENABLE_DEBUG_FEATURES", "true")
+ }
+ }
}
-/**
- * Apply baseline configurations for all Android projects (Application and Library).
- */
+/** Apply baseline configurations for all Android projects (Application and Library). */
@Suppress("UnstableApiUsage")
internal fun TestedExtension.configureCommonAndroidOptions() {
- compileSdkVersion(30)
+ compileSdkVersion(30)
- defaultConfig {
- minSdkVersion(23)
- targetSdkVersion(29)
- }
+ defaultConfig {
+ minSdkVersion(23)
+ targetSdkVersion(29)
+ }
- packagingOptions {
- exclude("**/*.version")
- exclude("**/*.txt")
- exclude("**/*.kotlin_module")
- exclude("**/plugin.properties")
- }
+ packagingOptions {
+ exclude("**/*.version")
+ exclude("**/*.txt")
+ exclude("**/*.kotlin_module")
+ exclude("**/plugin.properties")
+ }
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
- }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
- testOptions.animationsDisabled = true
+ testOptions.animationsDisabled = true
}
diff --git a/buildSrc/src/main/java/BinaryCompatibilityValidator.kt b/buildSrc/src/main/java/BinaryCompatibilityValidator.kt
index 8d755a86..6d7c53fb 100644
--- a/buildSrc/src/main/java/BinaryCompatibilityValidator.kt
+++ b/buildSrc/src/main/java/BinaryCompatibilityValidator.kt
@@ -8,9 +8,5 @@ import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
internal fun Project.configureBinaryCompatibilityValidator() {
- extensions.configure<ApiValidationExtension> {
- ignoredProjects = mutableSetOf(
- "app"
- )
- }
+ extensions.configure<ApiValidationExtension> { ignoredProjects = mutableSetOf("app") }
}
diff --git a/buildSrc/src/main/java/CrowdinDownloadPlugin.kt b/buildSrc/src/main/java/CrowdinDownloadPlugin.kt
index af32975f..03a7859c 100644
--- a/buildSrc/src/main/java/CrowdinDownloadPlugin.kt
+++ b/buildSrc/src/main/java/CrowdinDownloadPlugin.kt
@@ -14,63 +14,59 @@ import org.gradle.kotlin.dsl.register
class CrowdinDownloadPlugin : Plugin<Project> {
- override fun apply(project: Project) {
- with(project) {
- val extension = extensions.create<CrowdinExtension>("crowdin")
- afterEvaluate {
- val projectName = extension.projectName
- if (projectName.isEmpty()) {
- throw GradleException(
- """
+ override fun apply(project: Project) {
+ with(project) {
+ val extension = extensions.create<CrowdinExtension>("crowdin")
+ afterEvaluate {
+ val projectName = extension.projectName
+ if (projectName.isEmpty()) {
+ throw GradleException(
+ """
Applying `crowdin-plugin` requires a projectName to be configured via the "crowdin" extension.
- """.trimIndent()
- )
- }
- tasks.register<Download>("downloadCrowdin") {
- src("https://crowdin.com/backend/download/project/$projectName.zip")
- dest("$buildDir/translations.zip")
- overwrite(true)
- }
- tasks.register<Copy>("extractCrowdin") {
- setDependsOn(setOf("downloadCrowdin"))
- doFirst {
- File(buildDir, "translations").deleteRecursively()
- }
- from(zipTree("$buildDir/translations.zip"))
- into("$buildDir/translations")
- }
- tasks.register<Copy>("extractBaseStrings") {
- setDependsOn(setOf("extractCrowdin"))
- from("$buildDir/translations/${project.name}/src/main/res")
- into("${projectDir}/src/main/res")
- }
- tasks.register<Copy>("extractNonFreeStrings") {
- setDependsOn(setOf("extractCrowdin"))
- from("$buildDir/translations/") {
- exclude("app/")
- }
- into("$buildDir/nonFree-translations")
- doLast {
- File("$buildDir/nonFree-translations")
- .listFiles { file: File -> file.isDirectory }?.forEach { file ->
- val dest = File("${projectDir}/src/nonFree/values-${file.name}")
- val src = File(file, "app/src/nonFree/res/values/strings.xml")
- dest.mkdirs()
- src.renameTo(File(dest, "strings.xml"))
- }
- }
- }
- tasks.register("crowdin") {
- setDependsOn(setOf("extractBaseStrings", "extractNonFreeStrings"))
- if (!extension.skipCleanup) {
- doLast {
- File("$buildDir/translations").deleteRecursively()
- File("$buildDir/nonFree-translations").deleteRecursively()
- File("$buildDir/translations.zip").delete()
- }
- }
+ """.trimIndent())
+ }
+ tasks.register<Download>("downloadCrowdin") {
+ src("https://crowdin.com/backend/download/project/$projectName.zip")
+ dest("$buildDir/translations.zip")
+ overwrite(true)
+ }
+ tasks.register<Copy>("extractCrowdin") {
+ setDependsOn(setOf("downloadCrowdin"))
+ doFirst { File(buildDir, "translations").deleteRecursively() }
+ from(zipTree("$buildDir/translations.zip"))
+ into("$buildDir/translations")
+ }
+ tasks.register<Copy>("extractBaseStrings") {
+ setDependsOn(setOf("extractCrowdin"))
+ from("$buildDir/translations/${project.name}/src/main/res")
+ into("${projectDir}/src/main/res")
+ }
+ tasks.register<Copy>("extractNonFreeStrings") {
+ setDependsOn(setOf("extractCrowdin"))
+ from("$buildDir/translations/") { exclude("app/") }
+ into("$buildDir/nonFree-translations")
+ doLast {
+ File("$buildDir/nonFree-translations")
+ .listFiles { file: File -> file.isDirectory }
+ ?.forEach { file ->
+ val dest = File("${projectDir}/src/nonFree/values-${file.name}")
+ val src = File(file, "app/src/nonFree/res/values/strings.xml")
+ dest.mkdirs()
+ src.renameTo(File(dest, "strings.xml"))
}
+ }
+ }
+ tasks.register("crowdin") {
+ setDependsOn(setOf("extractBaseStrings", "extractNonFreeStrings"))
+ if (!extension.skipCleanup) {
+ doLast {
+ File("$buildDir/translations").deleteRecursively()
+ File("$buildDir/nonFree-translations").deleteRecursively()
+ File("$buildDir/translations.zip").delete()
}
+ }
}
+ }
}
+ }
}
diff --git a/buildSrc/src/main/java/CrowdinExtension.kt b/buildSrc/src/main/java/CrowdinExtension.kt
index ba244d62..e50d8e5c 100644
--- a/buildSrc/src/main/java/CrowdinExtension.kt
+++ b/buildSrc/src/main/java/CrowdinExtension.kt
@@ -5,15 +5,13 @@
open class CrowdinExtension {
- /**
- * Configure the project name on Crowdin
- */
- open var projectName = ""
+ /** Configure the project name on Crowdin */
+ open var projectName = ""
- /**
- * Don't delete downloaded and extracted translation archives from build directory.
- *
- * Useful for debugging.
- */
- open var skipCleanup = false
+ /**
+ * Don't delete downloaded and extracted translation archives from build directory.
+ *
+ * Useful for debugging.
+ */
+ open var skipCleanup = false
}
diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt
index f83227cb..d8090538 100644
--- a/buildSrc/src/main/java/Dependencies.kt
+++ b/buildSrc/src/main/java/Dependencies.kt
@@ -6,88 +6,92 @@
private const val KOTLIN_VERSION = "1.4.31"
object Plugins {
- const val androidGradlePlugin = "com.android.tools.build:gradle:4.1.2"
- const val binaryCompatibilityValidator = "org.jetbrains.kotlinx:binary-compatibility-validator:0.2.4"
- const val dokkaPlugin = "org.jetbrains.dokka:dokka-gradle-plugin:1.4.20"
- const val downloadTaskPlugin = "de.undercouch:gradle-download-task:4.1.1"
- const val kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION"
- const val ktfmtGradlePlugin = "com.ncorti.ktfmt.gradle:plugin:0.4.0"
- const val mavenPublishPlugin = "com.vanniktech:gradle-maven-publish-plugin:0.13.0"
- const val semver4j = "com.vdurmont:semver4j:3.1.0"
+ const val androidGradlePlugin = "com.android.tools.build:gradle:4.1.2"
+ const val binaryCompatibilityValidator =
+ "org.jetbrains.kotlinx:binary-compatibility-validator:0.2.4"
+ const val dokkaPlugin = "org.jetbrains.dokka:dokka-gradle-plugin:1.4.20"
+ const val downloadTaskPlugin = "de.undercouch:gradle-download-task:4.1.1"
+ const val kotlinGradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION"
+ const val ktfmtGradlePlugin = "com.ncorti.ktfmt.gradle:plugin:0.4.0"
+ const val mavenPublishPlugin = "com.vanniktech:gradle-maven-publish-plugin:0.13.0"
+ const val semver4j = "com.vdurmont:semver4j:3.1.0"
}
object Dependencies {
- object Kotlin {
- object Coroutines {
+ object Kotlin {
+ object Coroutines {
- private const val version = "1.4.2"
- const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version"
- const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version"
- }
+ private const val version = "1.4.2"
+ const val android = "org.jetbrains.kotlinx:kotlinx-coroutines-android:$version"
+ const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:$version"
}
+ }
+
+ object AndroidX {
+
+ private const val lifecycleVersion = "2.3.0-rc01"
+
+ const val activity_ktx = "androidx.activity:activity-ktx:1.3.0-alpha03"
+ const val annotation = "androidx.annotation:annotation:1.1.0"
+ const val autofill = "androidx.autofill:autofill:1.1.0"
+ const val appcompat = "androidx.appcompat:appcompat:1.3.0-beta01"
+ const val biometric_ktx = "androidx.biometric:biometric-ktx:1.2.0-alpha03"
+ const val constraint_layout = "androidx.constraintlayout:constraintlayout:2.1.0-alpha2"
+ const val core_ktx = "androidx.core:core-ktx:1.5.0-beta02"
+ const val documentfile = "androidx.documentfile:documentfile:1.0.1"
+ const val fragment_ktx = "androidx.fragment:fragment-ktx:1.3.0"
+ const val lifecycle_common = "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
+ const val lifecycle_livedata_ktx = "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
+ const val lifecycle_viewmodel_ktx =
+ "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
+ const val material = "com.google.android.material:material:1.3.0"
+ const val preference = "androidx.preference:preference:1.1.1"
+ const val recycler_view = "androidx.recyclerview:recyclerview:1.2.0-beta02"
+ const val recycler_view_selection = "androidx.recyclerview:recyclerview-selection:1.1.0"
+ const val security = "androidx.security:security-crypto:1.1.0-alpha03"
+ const val swiperefreshlayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01"
+ }
+
+ object FirstParty {
+
+ const val zxing_android_embedded =
+ "com.github.android-password-store:zxing-android-embedded:4.1.0-aps"
+ }
+
+ object ThirdParty {
+
+ const val bouncycastle = "org.bouncycastle:bcprov-jdk15on:1.67"
+ const val commons_codec = "commons-codec:commons-codec:1.14"
+ const val eddsa = "net.i2p.crypto:eddsa:0.3.0"
+ const val fastscroll = "me.zhanghai.android.fastscroll:library:1.1.5"
+ const val jgit = "org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r"
+ const val kotlin_result = "com.michael-bull.kotlin-result:kotlin-result:1.1.10"
+ const val leakcanary = "com.squareup.leakcanary:leakcanary-android:2.6"
+ const val modern_android_prefs = "de.maxr1998:modernandroidpreferences:2.0"
+ const val plumber = "com.squareup.leakcanary:plumber-android:2.6"
+ const val sshj = "com.hierynomus:sshj:0.31.0"
+ const val ssh_auth = "org.sufficientlysecure:sshauthentication-api:1.0"
+ const val timber = "com.jakewharton.timber:timber:4.7.1"
+ const val timberkt = "com.github.ajalt:timberkt:1.5.1"
+ const val whatthestack = "com.github.haroldadmin:WhatTheStack:0.3.0"
+ }
+
+ object NonFree {
+
+ const val google_play_auth_api_phone =
+ "com.google.android.gms:play-services-auth-api-phone:17.5.0"
+ }
+
+ object Testing {
+
+ const val junit = "junit:junit:4.13.1"
+ const val kotlin_test_junit = "org.jetbrains.kotlin:kotlin-test-junit:$KOTLIN_VERSION"
object AndroidX {
- private const val lifecycleVersion = "2.3.0-rc01"
-
- const val activity_ktx = "androidx.activity:activity-ktx:1.3.0-alpha03"
- const val annotation = "androidx.annotation:annotation:1.1.0"
- const val autofill = "androidx.autofill:autofill:1.1.0"
- const val appcompat = "androidx.appcompat:appcompat:1.3.0-beta01"
- const val biometric_ktx = "androidx.biometric:biometric-ktx:1.2.0-alpha03"
- const val constraint_layout = "androidx.constraintlayout:constraintlayout:2.1.0-alpha2"
- const val core_ktx = "androidx.core:core-ktx:1.5.0-beta02"
- const val documentfile = "androidx.documentfile:documentfile:1.0.1"
- const val fragment_ktx = "androidx.fragment:fragment-ktx:1.3.0"
- const val lifecycle_common = "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
- const val lifecycle_livedata_ktx = "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
- const val lifecycle_viewmodel_ktx = "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion"
- const val material = "com.google.android.material:material:1.3.0"
- const val preference = "androidx.preference:preference:1.1.1"
- const val recycler_view = "androidx.recyclerview:recyclerview:1.2.0-beta02"
- const val recycler_view_selection = "androidx.recyclerview:recyclerview-selection:1.1.0"
- const val security = "androidx.security:security-crypto:1.1.0-alpha03"
- const val swiperefreshlayout = "androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01"
- }
-
- object FirstParty {
-
- const val zxing_android_embedded = "com.github.android-password-store:zxing-android-embedded:4.1.0-aps"
- }
-
- object ThirdParty {
-
- const val bouncycastle = "org.bouncycastle:bcprov-jdk15on:1.67"
- const val commons_codec = "commons-codec:commons-codec:1.14"
- const val eddsa = "net.i2p.crypto:eddsa:0.3.0"
- const val fastscroll = "me.zhanghai.android.fastscroll:library:1.1.5"
- const val jgit = "org.eclipse.jgit:org.eclipse.jgit:3.7.1.201504261725-r"
- const val kotlin_result = "com.michael-bull.kotlin-result:kotlin-result:1.1.10"
- const val leakcanary = "com.squareup.leakcanary:leakcanary-android:2.6"
- const val modern_android_prefs = "de.maxr1998:modernandroidpreferences:2.0"
- const val plumber = "com.squareup.leakcanary:plumber-android:2.6"
- const val sshj = "com.hierynomus:sshj:0.31.0"
- const val ssh_auth = "org.sufficientlysecure:sshauthentication-api:1.0"
- const val timber = "com.jakewharton.timber:timber:4.7.1"
- const val timberkt = "com.github.ajalt:timberkt:1.5.1"
- const val whatthestack = "com.github.haroldadmin:WhatTheStack:0.3.0"
- }
-
- object NonFree {
-
- const val google_play_auth_api_phone = "com.google.android.gms:play-services-auth-api-phone:17.5.0"
- }
-
- object Testing {
-
- const val junit = "junit:junit:4.13.1"
- const val kotlin_test_junit = "org.jetbrains.kotlin:kotlin-test-junit:$KOTLIN_VERSION"
-
- object AndroidX {
-
- private const val version = "1.3.1-alpha03"
- const val runner = "androidx.test:runner:$version"
- const val rules = "androidx.test:rules:$version"
- }
+ private const val version = "1.3.1-alpha03"
+ const val runner = "androidx.test:runner:$version"
+ const val rules = "androidx.test:rules:$version"
}
+ }
}
diff --git a/buildSrc/src/main/java/KotlinCompilerArgs.kt b/buildSrc/src/main/java/KotlinCompilerArgs.kt
index 37d8edbd..fed92c22 100644
--- a/buildSrc/src/main/java/KotlinCompilerArgs.kt
+++ b/buildSrc/src/main/java/KotlinCompilerArgs.kt
@@ -3,6 +3,4 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
-internal val additionalCompilerArgs = listOf(
- "-Xopt-in=kotlin.RequiresOptIn"
-)
+internal val additionalCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn")
diff --git a/buildSrc/src/main/java/Ktfmt.kt b/buildSrc/src/main/java/Ktfmt.kt
index 9f134274..49a90a06 100644
--- a/buildSrc/src/main/java/Ktfmt.kt
+++ b/buildSrc/src/main/java/Ktfmt.kt
@@ -6,6 +6,6 @@
import com.ncorti.ktfmt.gradle.KtfmtExtension
fun KtfmtExtension.configureKtfmt() {
- googleStyle()
- maxWidth.set(120)
+ googleStyle()
+ maxWidth.set(120)
}
diff --git a/buildSrc/src/main/java/PasswordStorePlugin.kt b/buildSrc/src/main/java/PasswordStorePlugin.kt
index a61aa210..5d2d5dd9 100644
--- a/buildSrc/src/main/java/PasswordStorePlugin.kt
+++ b/buildSrc/src/main/java/PasswordStorePlugin.kt
@@ -19,36 +19,39 @@ import org.gradle.kotlin.dsl.withType
class PasswordStorePlugin : Plugin<Project> {
- override fun apply(project: Project) {
- project.configureForAllProjects()
+ override fun apply(project: Project) {
+ project.configureForAllProjects()
- if (project.isRoot) {
- project.configureForRootProject()
- }
+ if (project.isRoot) {
+ project.configureForRootProject()
+ }
- project.plugins.all {
- when (this) {
- is JavaPlugin,
- is JavaLibraryPlugin -> {
- project.tasks.withType<JavaCompile> {
- options.compilerArgs.add("-Xlint:unchecked")
- options.isDeprecation = true
- }
- }
- is LibraryPlugin -> {
- project.extensions.getByType<TestedExtension>().configureCommonAndroidOptions()
- }
- is AppPlugin -> {
- project.extensions.getByType<BaseAppModuleExtension>().configureAndroidApplicationOptions(project)
- project.extensions.getByType<BaseAppModuleExtension>().configureBuildSigning(project)
- project.extensions.getByType<TestedExtension>().configureCommonAndroidOptions()
- }
- is KtfmtPlugin -> {
- project.extensions.getByType<KtfmtExtension>().configureKtfmt()
- }
- }
+ project.plugins.all {
+ when (this) {
+ is JavaPlugin, is JavaLibraryPlugin -> {
+ project.tasks.withType<JavaCompile> {
+ options.compilerArgs.add("-Xlint:unchecked")
+ options.isDeprecation = true
+ }
+ }
+ is LibraryPlugin -> {
+ project.extensions.getByType<TestedExtension>().configureCommonAndroidOptions()
+ }
+ is AppPlugin -> {
+ project
+ .extensions
+ .getByType<BaseAppModuleExtension>()
+ .configureAndroidApplicationOptions(project)
+ project.extensions.getByType<BaseAppModuleExtension>().configureBuildSigning(project)
+ project.extensions.getByType<TestedExtension>().configureCommonAndroidOptions()
+ }
+ is KtfmtPlugin -> {
+ project.extensions.getByType<KtfmtExtension>().configureKtfmt()
}
+ }
}
+ }
}
-private val Project.isRoot get() = this == this.rootProject
+private val Project.isRoot
+ get() = this == this.rootProject
diff --git a/buildSrc/src/main/java/SigningConfig.kt b/buildSrc/src/main/java/SigningConfig.kt
index c1abc161..eaa7433c 100644
--- a/buildSrc/src/main/java/SigningConfig.kt
+++ b/buildSrc/src/main/java/SigningConfig.kt
@@ -9,26 +9,24 @@ import org.gradle.api.Project
private const val KEYSTORE_CONFIG_PATH = "keystore.properties"
-/**
- * Configure signing for all build types.
- */
+/** Configure signing for all build types. */
@Suppress("UnstableApiUsage")
internal fun BaseAppModuleExtension.configureBuildSigning(project: Project) {
- with(project) {
- val keystoreConfigFile = rootProject.layout.projectDirectory.file(KEYSTORE_CONFIG_PATH)
- if (!keystoreConfigFile.asFile.exists()) return
- val contents = providers.fileContents(keystoreConfigFile).asText.forUseAtConfigurationTime()
- val keystoreProperties = Properties()
- keystoreProperties.load(contents.get().byteInputStream())
- signingConfigs {
- register("release") {
- keyAlias = keystoreProperties["keyAlias"] as String
- keyPassword = keystoreProperties["keyPassword"] as String
- storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
- storePassword = keystoreProperties["storePassword"] as String
- }
- }
- val signingConfig = signingConfigs.getByName("release")
- buildTypes.all { setSigningConfig(signingConfig) }
+ with(project) {
+ val keystoreConfigFile = rootProject.layout.projectDirectory.file(KEYSTORE_CONFIG_PATH)
+ if (!keystoreConfigFile.asFile.exists()) return
+ val contents = providers.fileContents(keystoreConfigFile).asText.forUseAtConfigurationTime()
+ val keystoreProperties = Properties()
+ keystoreProperties.load(contents.get().byteInputStream())
+ signingConfigs {
+ register("release") {
+ keyAlias = keystoreProperties["keyAlias"] as String
+ keyPassword = keystoreProperties["keyPassword"] as String
+ storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
+ storePassword = keystoreProperties["storePassword"] as String
+ }
}
+ val signingConfig = signingConfigs.getByName("release")
+ buildTypes.all { setSigningConfig(signingConfig) }
+ }
}
diff --git a/buildSrc/src/main/java/VersioningPlugin.kt b/buildSrc/src/main/java/VersioningPlugin.kt
index a2c9ff81..f05aa34e 100644
--- a/buildSrc/src/main/java/VersioningPlugin.kt
+++ b/buildSrc/src/main/java/VersioningPlugin.kt
@@ -3,7 +3,6 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
-
import com.android.build.gradle.internal.plugins.AppPlugin
import com.vdurmont.semver4j.Semver
import java.io.OutputStream
@@ -14,98 +13,94 @@ import org.gradle.api.Project
private const val VERSIONING_PROP_FILE = "version.properties"
private const val VERSIONING_PROP_VERSION_NAME = "versioning-plugin.versionName"
private const val VERSIONING_PROP_VERSION_CODE = "versioning-plugin.versionCode"
-private const val VERSIONING_PROP_COMMENT = """
+private const val VERSIONING_PROP_COMMENT =
+ """
This file was automatically generated by 'versioning-plugin'. DO NOT EDIT MANUALLY.
"""
/**
* A Gradle [Plugin] that takes a [Project] with the [AppPlugin] applied and dynamically sets the
* versionCode and versionName properties based on values read from a [VERSIONING_PROP_FILE] file in
- * the [Project.getBuildDir] directory. It also adds Gradle tasks to bump the major, minor, and patch
- * versions along with one to prepare the next snapshot.
+ * the [Project.getBuildDir] directory. It also adds Gradle tasks to bump the major, minor, and
+ * patch versions along with one to prepare the next snapshot.
*/
-@Suppress(
- "UnstableApiUsage",
- "NAME_SHADOWING"
-)
+@Suppress("UnstableApiUsage", "NAME_SHADOWING")
class VersioningPlugin : Plugin<Project> {
- /**
- * Generate the Android 'versionCode' property
- */
- private fun Semver.androidCode(): Int {
- return major * 1_00_00 +
- minor * 1_00 +
- patch
- }
+ /** Generate the Android 'versionCode' property */
+ private fun Semver.androidCode(): Int {
+ return major * 1_00_00 + minor * 1_00 + patch
+ }
- /**
- * Write an Android-specific variant of [this] to [stream]
- */
- private fun Semver.writeForAndroid(stream: OutputStream) {
- val newVersionCode = androidCode()
- val props = Properties()
- props.setProperty(VERSIONING_PROP_VERSION_CODE, "$newVersionCode")
- props.setProperty(VERSIONING_PROP_VERSION_NAME, toString())
- props.store(stream, VERSIONING_PROP_COMMENT)
- }
+ /** Write an Android-specific variant of [this] to [stream] */
+ private fun Semver.writeForAndroid(stream: OutputStream) {
+ val newVersionCode = androidCode()
+ val props = Properties()
+ props.setProperty(VERSIONING_PROP_VERSION_CODE, "$newVersionCode")
+ props.setProperty(VERSIONING_PROP_VERSION_NAME, toString())
+ props.store(stream, VERSIONING_PROP_COMMENT)
+ }
- override fun apply(project: Project) {
- with(project) {
- val appPlugin = requireNotNull(plugins.findPlugin(AppPlugin::class.java)) {
- "Plugin 'com.android.application' must be applied to use this plugin"
- }
- val propFile = layout.projectDirectory.file(VERSIONING_PROP_FILE)
- require(propFile.asFile.exists()) {
- "A 'version.properties' file must exist in the project subdirectory to use this plugin"
- }
- val contents = providers.fileContents(propFile).asText.forUseAtConfigurationTime()
- val versionProps = Properties().also { it.load(contents.get().byteInputStream()) }
- val versionName = requireNotNull(versionProps.getProperty(VERSIONING_PROP_VERSION_NAME)) {
- "version.properties must contain a '$VERSIONING_PROP_VERSION_NAME' property"
- }
- val versionCode = requireNotNull(versionProps.getProperty(VERSIONING_PROP_VERSION_CODE).toInt()) {
- "version.properties must contain a '$VERSIONING_PROP_VERSION_CODE' property"
- }
- appPlugin.extension.defaultConfig.versionName = versionName
- appPlugin.extension.defaultConfig.versionCode = versionCode
- afterEvaluate {
- val version = Semver(versionName)
- tasks.register("clearPreRelease") {
- doLast {
- version.withClearedSuffix()
- .writeForAndroid(propFile.asFile.outputStream())
- }
- }
- tasks.register("bumpMajor") {
- doLast {
- version.withIncMajor()
- .withClearedSuffix()
- .writeForAndroid(propFile.asFile.outputStream())
- }
- }
- tasks.register("bumpMinor") {
- doLast {
- version.withIncMinor()
- .withClearedSuffix()
- .writeForAndroid(propFile.asFile.outputStream())
- }
- }
- tasks.register("bumpPatch") {
- doLast {
- version.withIncPatch()
- .withClearedSuffix()
- .writeForAndroid(propFile.asFile.outputStream())
- }
- }
- tasks.register("bumpSnapshot") {
- doLast {
- version.withIncMinor()
- .withSuffix("SNAPSHOT")
- .writeForAndroid(propFile.asFile.outputStream())
- }
- }
- }
+ override fun apply(project: Project) {
+ with(project) {
+ val appPlugin =
+ requireNotNull(plugins.findPlugin(AppPlugin::class.java)) {
+ "Plugin 'com.android.application' must be applied to use this plugin"
+ }
+ val propFile = layout.projectDirectory.file(VERSIONING_PROP_FILE)
+ require(propFile.asFile.exists()) {
+ "A 'version.properties' file must exist in the project subdirectory to use this plugin"
+ }
+ val contents = providers.fileContents(propFile).asText.forUseAtConfigurationTime()
+ val versionProps = Properties().also { it.load(contents.get().byteInputStream()) }
+ val versionName =
+ requireNotNull(versionProps.getProperty(VERSIONING_PROP_VERSION_NAME)) {
+ "version.properties must contain a '$VERSIONING_PROP_VERSION_NAME' property"
+ }
+ val versionCode =
+ requireNotNull(versionProps.getProperty(VERSIONING_PROP_VERSION_CODE).toInt()) {
+ "version.properties must contain a '$VERSIONING_PROP_VERSION_CODE' property"
+ }
+ appPlugin.extension.defaultConfig.versionName = versionName
+ appPlugin.extension.defaultConfig.versionCode = versionCode
+ afterEvaluate {
+ val version = Semver(versionName)
+ tasks.register("clearPreRelease") {
+ doLast { version.withClearedSuffix().writeForAndroid(propFile.asFile.outputStream()) }
+ }
+ tasks.register("bumpMajor") {
+ doLast {
+ version
+ .withIncMajor()
+ .withClearedSuffix()
+ .writeForAndroid(propFile.asFile.outputStream())
+ }
+ }
+ tasks.register("bumpMinor") {
+ doLast {
+ version
+ .withIncMinor()
+ .withClearedSuffix()
+ .writeForAndroid(propFile.asFile.outputStream())
+ }
+ }
+ tasks.register("bumpPatch") {
+ doLast {
+ version
+ .withIncPatch()
+ .withClearedSuffix()
+ .writeForAndroid(propFile.asFile.outputStream())
+ }
+ }
+ tasks.register("bumpSnapshot") {
+ doLast {
+ version
+ .withIncMinor()
+ .withSuffix("SNAPSHOT")
+ .writeForAndroid(propFile.asFile.outputStream())
+ }
}
+ }
}
+ }
}
diff --git a/openpgp-ktx/build.gradle.kts b/openpgp-ktx/build.gradle.kts
index c82e65a1..c809466c 100644
--- a/openpgp-ktx/build.gradle.kts
+++ b/openpgp-ktx/build.gradle.kts
@@ -4,30 +4,20 @@
*/
plugins {
- id("com.android.library")
- id("com.vanniktech.maven.publish")
- kotlin("android")
- `aps-plugin`
+ id("com.android.library")
+ id("com.vanniktech.maven.publish")
+ kotlin("android")
+ `aps-plugin`
}
android {
- defaultConfig {
- consumerProguardFiles("consumer-proguard-rules.pro")
- }
+ defaultConfig { consumerProguardFiles("consumer-proguard-rules.pro") }
- buildFeatures.aidl = true
+ buildFeatures.aidl = true
- kotlin {
- explicitApi()
- }
+ kotlin { explicitApi() }
- kotlinOptions {
- freeCompilerArgs = freeCompilerArgs + listOf(
- "-Xexplicit-api=strict"
- )
- }
+ kotlinOptions { freeCompilerArgs = freeCompilerArgs + listOf("-Xexplicit-api=strict") }
}
-dependencies {
- implementation(Dependencies.Kotlin.Coroutines.core)
-}
+dependencies { implementation(Dependencies.Kotlin.Coroutines.core) }
diff --git a/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/AutocryptPeerUpdate.kt b/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/AutocryptPeerUpdate.kt
index 2f610b3e..e6a189b9 100644
--- a/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/AutocryptPeerUpdate.kt
+++ b/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/AutocryptPeerUpdate.kt
@@ -11,99 +11,93 @@ import java.util.Date
public class AutocryptPeerUpdate() : Parcelable {
- private var keyData: ByteArray? = null
- private var effectiveDate: Date? = null
- private lateinit var preferEncrypt: PreferEncrypt
-
- internal constructor(
- keyData: ByteArray?,
- effectiveDate: Date?,
- preferEncrypt: PreferEncrypt
- ) : this() {
- this.keyData = keyData
- this.effectiveDate = effectiveDate
- this.preferEncrypt = preferEncrypt
+ private var keyData: ByteArray? = null
+ private var effectiveDate: Date? = null
+ private lateinit var preferEncrypt: PreferEncrypt
+
+ internal constructor(keyData: ByteArray?, effectiveDate: Date?, preferEncrypt: PreferEncrypt) : this() {
+ this.keyData = keyData
+ this.effectiveDate = effectiveDate
+ this.preferEncrypt = preferEncrypt
+ }
+
+ private constructor(source: Parcel, version: Int) : this() {
+ keyData = source.createByteArray()
+ effectiveDate = if (source.readInt() != 0) Date(source.readLong()) else null
+ preferEncrypt = PreferEncrypt.values()[source.readInt()]
+ }
+
+ public fun createAutocryptPeerUpdate(keyData: ByteArray?, timestamp: Date?): AutocryptPeerUpdate {
+ return AutocryptPeerUpdate(keyData, timestamp, PreferEncrypt.NOPREFERENCE)
+ }
+
+ public fun getKeyData(): ByteArray? {
+ return keyData
+ }
+
+ public fun hasKeyData(): Boolean {
+ return keyData != null
+ }
+
+ public fun getEffectiveDate(): Date? {
+ return effectiveDate
+ }
+
+ public fun getPreferEncrypt(): PreferEncrypt? {
+ return preferEncrypt
+ }
+
+ override fun describeContents(): Int {
+ return 0
+ }
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ /**
+ * NOTE: When adding fields in the process of updating this API, make sure to bump
+ * [.PARCELABLE_VERSION].
+ */
+ dest.writeInt(PARCELABLE_VERSION)
+ // Inject a placeholder that will store the parcel size from this point on
+ // (not including the size itself).
+ val sizePosition = dest.dataPosition()
+ dest.writeInt(0)
+ val startPosition = dest.dataPosition()
+ // version 1
+ dest.writeByteArray(keyData)
+ if (effectiveDate != null) {
+ dest.writeInt(1)
+ dest.writeLong(effectiveDate!!.time)
+ } else {
+ dest.writeInt(0)
}
-
- private constructor(source: Parcel, version: Int) : this() {
- keyData = source.createByteArray()
- effectiveDate = if (source.readInt() != 0) Date(source.readLong()) else null
- preferEncrypt = PreferEncrypt.values()[source.readInt()]
- }
-
- public fun createAutocryptPeerUpdate(
- keyData: ByteArray?,
- timestamp: Date?
- ): AutocryptPeerUpdate {
- return AutocryptPeerUpdate(keyData, timestamp, PreferEncrypt.NOPREFERENCE)
- }
-
- public fun getKeyData(): ByteArray? {
- return keyData
- }
-
- public fun hasKeyData(): Boolean {
- return keyData != null
- }
-
- public fun getEffectiveDate(): Date? {
- return effectiveDate
- }
-
- public fun getPreferEncrypt(): PreferEncrypt? {
- return preferEncrypt
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- override fun writeToParcel(dest: Parcel, flags: Int) {
- /**
- * NOTE: When adding fields in the process of updating this API, make sure to bump
- * [.PARCELABLE_VERSION].
- */
- dest.writeInt(PARCELABLE_VERSION)
- // Inject a placeholder that will store the parcel size from this point on
- // (not including the size itself).
- val sizePosition = dest.dataPosition()
- dest.writeInt(0)
- val startPosition = dest.dataPosition()
- // version 1
- dest.writeByteArray(keyData)
- if (effectiveDate != null) {
- dest.writeInt(1)
- dest.writeLong(effectiveDate!!.time)
- } else {
- dest.writeInt(0)
- }
- dest.writeInt(preferEncrypt.ordinal)
- // Go back and write the size
- val parcelableSize = dest.dataPosition() - startPosition
- dest.setDataPosition(sizePosition)
- dest.writeInt(parcelableSize)
- dest.setDataPosition(startPosition + parcelableSize)
+ dest.writeInt(preferEncrypt.ordinal)
+ // Go back and write the size
+ val parcelableSize = dest.dataPosition() - startPosition
+ dest.setDataPosition(sizePosition)
+ dest.writeInt(parcelableSize)
+ dest.setDataPosition(startPosition + parcelableSize)
+ }
+
+ public companion object CREATOR : Creator<AutocryptPeerUpdate> {
+
+ private const val PARCELABLE_VERSION = 1
+ override fun createFromParcel(source: Parcel): AutocryptPeerUpdate? {
+ val version = source.readInt() // parcelableVersion
+ val parcelableSize = source.readInt()
+ val startPosition = source.dataPosition()
+ val vr = AutocryptPeerUpdate(source, version)
+ // skip over all fields added in future versions of this parcel
+ source.setDataPosition(startPosition + parcelableSize)
+ return vr
}
- public companion object CREATOR : Creator<AutocryptPeerUpdate> {
-
- private const val PARCELABLE_VERSION = 1
- override fun createFromParcel(source: Parcel): AutocryptPeerUpdate? {
- val version = source.readInt() // parcelableVersion
- val parcelableSize = source.readInt()
- val startPosition = source.dataPosition()
- val vr = AutocryptPeerUpdate(source, version)
- // skip over all fields added in future versions of this parcel
- source.setDataPosition(startPosition + parcelableSize)
- return vr
- }
-
- override fun newArray(size: Int): Array<AutocryptPeerUpdate?>? {
- return arrayOfNulls(size)
- }
+ override fun newArray(size: Int): Array<AutocryptPeerUpdate?>? {
+ return arrayOfNulls(size)
}
+ }
- public enum class PreferEncrypt {
- NOPREFERENCE, MUTUAL
- }
+ public enum class PreferEncrypt {
+ NOPREFERENCE,
+ MUTUAL
+ }
}
diff --git a/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpApi.kt b/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpApi.kt
index 5badc0c8..aa5a2f84 100644
--- a/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpApi.kt
+++ b/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpApi.kt
@@ -21,382 +21,335 @@ import org.openintents.openpgp.OpenPgpError
public class OpenPgpApi(private val context: Context, private val service: IOpenPgpService2) {
- private val pipeIdGen: AtomicInteger = AtomicInteger()
-
- public suspend fun executeApiAsync(
- data: Intent?,
- inputStream: InputStream?,
- outputStream: OutputStream?,
- callback: (intent: Intent?) -> Unit
- ) {
- val result = executeApi(data, inputStream, outputStream)
- withContext(Dispatchers.Main) {
- callback.invoke(result)
+ private val pipeIdGen: AtomicInteger = AtomicInteger()
+
+ public suspend fun executeApiAsync(
+ data: Intent?,
+ inputStream: InputStream?,
+ outputStream: OutputStream?,
+ callback: (intent: Intent?) -> Unit
+ ) {
+ val result = executeApi(data, inputStream, outputStream)
+ withContext(Dispatchers.Main) { callback.invoke(result) }
+ }
+
+ public fun executeApi(data: Intent?, inputStream: InputStream?, outputStream: OutputStream?): Intent? {
+ var input: ParcelFileDescriptor? = null
+ return try {
+ if (inputStream != null) {
+ input = ParcelFileDescriptorUtil.pipeFrom(inputStream)
+ }
+ executeApi(data, input, outputStream)
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception in executeApi call", e)
+ val result = Intent()
+ result.putExtra(RESULT_CODE, RESULT_CODE_ERROR)
+ result.putExtra(RESULT_ERROR, OpenPgpError(OpenPgpError.CLIENT_SIDE_ERROR, e.message))
+ result
+ } finally {
+ if (input != null) {
+ try {
+ input.close()
+ } catch (e: IOException) {
+ Log.e(TAG, "IOException when closing ParcelFileDescriptor!", e)
}
+ }
}
-
- public fun executeApi(data: Intent?, inputStream: InputStream?, outputStream: OutputStream?): Intent? {
- var input: ParcelFileDescriptor? = null
- return try {
- if (inputStream != null) {
- input = ParcelFileDescriptorUtil.pipeFrom(inputStream)
- }
- executeApi(data, input, outputStream)
- } catch (e: Exception) {
- Log.e(TAG, "Exception in executeApi call", e)
- val result = Intent()
- result.putExtra(RESULT_CODE, RESULT_CODE_ERROR)
- result.putExtra(
- RESULT_ERROR,
- OpenPgpError(
- OpenPgpError.CLIENT_SIDE_ERROR,
- e.message
- )
- )
- result
- } finally {
- if (input != null) {
- try {
- input.close()
- } catch (e: IOException) {
- Log.e(TAG, "IOException when closing ParcelFileDescriptor!", e)
- }
- }
+ }
+
+ /** InputStream and OutputStreams are always closed after operating on them! */
+ private fun executeApi(data: Intent?, input: ParcelFileDescriptor?, os: OutputStream?): Intent? {
+ var output: ParcelFileDescriptor? = null
+ return try {
+ // always send version from client
+ data?.putExtra(EXTRA_API_VERSION, API_VERSION)
+ val result: Intent
+ var pumpThread: Thread? = null
+ var outputPipeId = 0
+ if (os != null) {
+ outputPipeId = pipeIdGen.incrementAndGet()
+ output = service.createOutputPipe(outputPipeId)
+ pumpThread = ParcelFileDescriptorUtil.pipeTo(os, output)
+ }
+ // blocks until result is ready
+ result = service.execute(data, input, outputPipeId)
+ // set class loader to current context to allow unparcelling
+ // of OpenPgpError and OpenPgpSignatureResult
+ // http://stackoverflow.com/a/3806769
+ result.setExtrasClassLoader(context.classLoader)
+ // wait for ALL data being pumped from remote side
+ pumpThread?.join()
+ result
+ } catch (e: Exception) {
+ Log.e(TAG, "Exception in executeApi call", e)
+ val result = Intent()
+ result.putExtra(RESULT_CODE, RESULT_CODE_ERROR)
+ result.putExtra(RESULT_ERROR, OpenPgpError(OpenPgpError.CLIENT_SIDE_ERROR, e.message))
+ result
+ } finally {
+ // close() is required to halt the TransferThread
+ if (output != null) {
+ try {
+ output.close()
+ } catch (e: IOException) {
+ Log.e(TAG, "IOException when closing ParcelFileDescriptor!", e)
}
+ }
}
+ }
+
+ public companion object {
+
+ private const val TAG = "OpenPgp API"
+
+ public const val SERVICE_INTENT_2: String = "org.openintents.openpgp.IOpenPgpService2"
+
+ /** see CHANGELOG.md */
+ public const val API_VERSION: Int = 11
/**
- * InputStream and OutputStreams are always closed after operating on them!
+ * General extras --------------
+ *
+ * required extras: int EXTRA_API_VERSION (always required)
+ *
+ * returned extras: int RESULT_CODE (RESULT_CODE_ERROR, RESULT_CODE_SUCCESS or
+ * RESULT_CODE_USER_INTERACTION_REQUIRED) OpenPgpError RESULT_ERROR (if RESULT_CODE ==
+ * RESULT_CODE_ERROR) PendingIntent RESULT_INTENT (if RESULT_CODE ==
+ * RESULT_CODE_USER_INTERACTION_REQUIRED)
*/
- private fun executeApi(
- data: Intent?,
- input: ParcelFileDescriptor?,
- os: OutputStream?
- ): Intent? {
- var output: ParcelFileDescriptor? = null
- return try {
- // always send version from client
- data?.putExtra(EXTRA_API_VERSION, API_VERSION)
- val result: Intent
- var pumpThread: Thread? = null
- var outputPipeId = 0
- if (os != null) {
- outputPipeId = pipeIdGen.incrementAndGet()
- output = service.createOutputPipe(outputPipeId)
- pumpThread = ParcelFileDescriptorUtil.pipeTo(os, output)
- }
- // blocks until result is ready
- result = service.execute(data, input, outputPipeId)
- // set class loader to current context to allow unparcelling
- // of OpenPgpError and OpenPgpSignatureResult
- // http://stackoverflow.com/a/3806769
- result.setExtrasClassLoader(context.classLoader)
- // wait for ALL data being pumped from remote side
- pumpThread?.join()
- result
- } catch (e: Exception) {
- Log.e(TAG, "Exception in executeApi call", e)
- val result = Intent()
- result.putExtra(RESULT_CODE, RESULT_CODE_ERROR)
- result.putExtra(
- RESULT_ERROR,
- OpenPgpError(
- OpenPgpError.CLIENT_SIDE_ERROR,
- e.message
- )
- )
- result
- } finally {
- // close() is required to halt the TransferThread
- if (output != null) {
- try {
- output.close()
- } catch (e: IOException) {
- Log.e(TAG, "IOException when closing ParcelFileDescriptor!", e)
- }
- }
- }
- }
- public companion object {
-
- private const val TAG = "OpenPgp API"
-
- public const val SERVICE_INTENT_2: String = "org.openintents.openpgp.IOpenPgpService2"
-
- /**
- * see CHANGELOG.md
- */
- public const val API_VERSION: Int = 11
-
- /**
- * General extras
- * --------------
- *
- * required extras:
- * int EXTRA_API_VERSION (always required)
- *
- * returned extras:
- * int RESULT_CODE (RESULT_CODE_ERROR, RESULT_CODE_SUCCESS or RESULT_CODE_USER_INTERACTION_REQUIRED)
- * OpenPgpError RESULT_ERROR (if RESULT_CODE == RESULT_CODE_ERROR)
- * PendingIntent RESULT_INTENT (if RESULT_CODE == RESULT_CODE_USER_INTERACTION_REQUIRED)
- */
-
- /**
- * General extras
- * --------------
- *
- * required extras:
- * int EXTRA_API_VERSION (always required)
- *
- * returned extras:
- * int RESULT_CODE (RESULT_CODE_ERROR, RESULT_CODE_SUCCESS or RESULT_CODE_USER_INTERACTION_REQUIRED)
- * OpenPgpError RESULT_ERROR (if RESULT_CODE == RESULT_CODE_ERROR)
- * PendingIntent RESULT_INTENT (if RESULT_CODE == RESULT_CODE_USER_INTERACTION_REQUIRED)
- */
- /**
- * This action performs no operation, but can be used to check if the App has permission
- * to access the API in general, returning a user interaction PendingIntent otherwise.
- * This can be used to trigger the permission dialog explicitly.
- *
- * This action uses no extras.
- */
- public const val ACTION_CHECK_PERMISSION: String = "org.openintents.openpgp.action.CHECK_PERMISSION"
-
- /**
- * Sign text resulting in a cleartext signature
- * Some magic pre-processing of the text is done to convert it to a format usable for
- * cleartext signatures per RFC 4880 before the text is actually signed:
- * - end cleartext with newline
- * - remove whitespaces on line endings
- *
- * required extras:
- * long EXTRA_SIGN_KEY_ID (key id of signing key)
- *
- * optional extras:
- * char[] EXTRA_PASSPHRASE (key passphrase)
- */
- public const val ACTION_CLEARTEXT_SIGN: String = "org.openintents.openpgp.action.CLEARTEXT_SIGN"
-
- /**
- * Sign text or binary data resulting in a detached signature.
- * No OutputStream necessary for ACTION_DETACHED_SIGN (No magic pre-processing like in ACTION_CLEARTEXT_SIGN)!
- * The detached signature is returned separately in RESULT_DETACHED_SIGNATURE.
- *
- * required extras:
- * long EXTRA_SIGN_KEY_ID (key id of signing key)
- *
- * optional extras:
- * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for detached signature)
- * char[] EXTRA_PASSPHRASE (key passphrase)
- *
- * returned extras:
- * byte[] RESULT_DETACHED_SIGNATURE
- * String RESULT_SIGNATURE_MICALG (contains the name of the used signature algorithm as a string)
- */
- public const val ACTION_DETACHED_SIGN: String = "org.openintents.openpgp.action.DETACHED_SIGN"
-
- /**
- * Encrypt
- *
- * required extras:
- * String[] EXTRA_USER_IDS (=emails of recipients, if more than one key has a user_id, a PendingIntent is returned via RESULT_INTENT)
- * or
- * long[] EXTRA_KEY_IDS
- *
- * optional extras:
- * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for output)
- * char[] EXTRA_PASSPHRASE (key passphrase)
- * String EXTRA_ORIGINAL_FILENAME (original filename to be encrypted as metadata)
- * boolean EXTRA_ENABLE_COMPRESSION (enable ZLIB compression, default ist true)
- */
- public const val ACTION_ENCRYPT: String = "org.openintents.openpgp.action.ENCRYPT"
-
- /**
- * Sign and encrypt
- *
- * required extras:
- * String[] EXTRA_USER_IDS (=emails of recipients, if more than one key has a user_id, a PendingIntent is returned via RESULT_INTENT)
- * or
- * long[] EXTRA_KEY_IDS
- *
- * optional extras:
- * long EXTRA_SIGN_KEY_ID (key id of signing key)
- * boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for output)
- * char[] EXTRA_PASSPHRASE (key passphrase)
- * String EXTRA_ORIGINAL_FILENAME (original filename to be encrypted as metadata)
- * boolean EXTRA_ENABLE_COMPRESSION (enable ZLIB compression, default ist true)
- */
- public const val ACTION_SIGN_AND_ENCRYPT: String = "org.openintents.openpgp.action.SIGN_AND_ENCRYPT"
-
- public const val ACTION_QUERY_AUTOCRYPT_STATUS: String =
- "org.openintents.openpgp.action.QUERY_AUTOCRYPT_STATUS"
-
- /**
- * Decrypts and verifies given input stream. This methods handles encrypted-only, signed-and-encrypted,
- * and also signed-only input.
- * OutputStream is optional, e.g., for verifying detached signatures!
- *
- * If OpenPgpSignatureResult.getResult() == OpenPgpSignatureResult.RESULT_KEY_MISSING
- * in addition a PendingIntent is returned via RESULT_INTENT to download missing keys.
- * On all other status, in addition a PendingIntent is returned via RESULT_INTENT to open
- * the key view in OpenKeychain.
- *
- * optional extras:
- * byte[] EXTRA_DETACHED_SIGNATURE (detached signature)
- *
- * returned extras:
- * OpenPgpSignatureResult RESULT_SIGNATURE
- * OpenPgpDecryptionResult RESULT_DECRYPTION
- * OpenPgpDecryptMetadata RESULT_METADATA
- * String RESULT_CHARSET (charset which was specified in the headers of ascii armored input, if any)
- */
- public const val ACTION_DECRYPT_VERIFY: String = "org.openintents.openpgp.action.DECRYPT_VERIFY"
-
- /**
- * Decrypts the header of an encrypted file to retrieve metadata such as original filename.
- *
- * This does not decrypt the actual content of the file.
- *
- * returned extras:
- * OpenPgpDecryptMetadata RESULT_METADATA
- * String RESULT_CHARSET (charset which was specified in the headers of ascii armored input, if any)
- */
- public const val ACTION_DECRYPT_METADATA: String = "org.openintents.openpgp.action.DECRYPT_METADATA"
-
- /**
- * Select key id for signing
- *
- * optional extras:
- * String EXTRA_USER_ID
- *
- * returned extras:
- * long EXTRA_SIGN_KEY_ID
- */
- public const val ACTION_GET_SIGN_KEY_ID: String = "org.openintents.openpgp.action.GET_SIGN_KEY_ID"
-
- /**
- * Get key ids based on given user ids (=emails)
- *
- * required extras:
- * String[] EXTRA_USER_IDS
- *
- * returned extras:
- * long[] RESULT_KEY_IDS
- */
- public const val ACTION_GET_KEY_IDS: String = "org.openintents.openpgp.action.GET_KEY_IDS"
-
- /**
- * This action returns RESULT_CODE_SUCCESS if the OpenPGP Provider already has the key
- * corresponding to the given key id in its database.
- *
- * It returns RESULT_CODE_USER_INTERACTION_REQUIRED if the Provider does not have the key.
- * The PendingIntent from RESULT_INTENT can be used to retrieve those from a keyserver.
- *
- * If an Output stream has been defined the whole public key is returned.
- * required extras:
- * long EXTRA_KEY_ID
- *
- * optional extras:
- * String EXTRA_REQUEST_ASCII_ARMOR (request that the returned key is encoded in ASCII Armor)
- */
- public const val ACTION_GET_KEY: String = "org.openintents.openpgp.action.GET_KEY"
-
- /**
- * Backup all keys given by EXTRA_KEY_IDS and if requested their secret parts.
- * The encrypted backup will be written to the OutputStream.
- * The client app has no access to the backup code used to encrypt the backup!
- * This operation always requires user interaction with RESULT_CODE_USER_INTERACTION_REQUIRED!
- *
- * required extras:
- * long[] EXTRA_KEY_IDS (keys that should be included in the backup)
- * boolean EXTRA_BACKUP_SECRET (also backup secret keys)
- */
- public const val ACTION_BACKUP: String = "org.openintents.openpgp.action.BACKUP"
-
- public const val ACTION_UPDATE_AUTOCRYPT_PEER: String =
- "org.openintents.openpgp.action.UPDATE_AUTOCRYPT_PEER"
-
- /* Intent extras */
- public const val EXTRA_API_VERSION: String = "api_version"
-
- // ACTION_DETACHED_SIGN, ENCRYPT, SIGN_AND_ENCRYPT, DECRYPT_VERIFY
- // request ASCII Armor for output
- // OpenPGP Radix-64, 33 percent overhead compared to binary, see http://tools.ietf.org/html/rfc4880#page-53)
- public const val EXTRA_REQUEST_ASCII_ARMOR: String = "ascii_armor"
-
- // ACTION_DETACHED_SIGN
- public const val RESULT_DETACHED_SIGNATURE: String = "detached_signature"
- public const val RESULT_SIGNATURE_MICALG: String = "signature_micalg"
-
- // ENCRYPT, SIGN_AND_ENCRYPT, QUERY_AUTOCRYPT_STATUS
- public const val EXTRA_USER_IDS: String = "user_ids"
- public const val EXTRA_KEY_IDS: String = "key_ids"
- public const val EXTRA_KEY_IDS_SELECTED: String = "key_ids_selected"
- public const val EXTRA_SIGN_KEY_ID: String = "sign_key_id"
-
- public const val RESULT_KEYS_CONFIRMED: String = "keys_confirmed"
- public const val RESULT_AUTOCRYPT_STATUS: String = "autocrypt_status"
- public const val AUTOCRYPT_STATUS_UNAVAILABLE: Int = 0
- public const val AUTOCRYPT_STATUS_DISCOURAGE: Int = 1
- public const val AUTOCRYPT_STATUS_AVAILABLE: Int = 2
- public const val AUTOCRYPT_STATUS_MUTUAL: Int = 3
-
- // optional extras:
- public const val EXTRA_PASSPHRASE: String = "passphrase"
- public const val EXTRA_ORIGINAL_FILENAME: String = "original_filename"
- public const val EXTRA_ENABLE_COMPRESSION: String = "enable_compression"
- public const val EXTRA_OPPORTUNISTIC_ENCRYPTION: String = "opportunistic"
-
- // GET_SIGN_KEY_ID
- public const val EXTRA_USER_ID: String = "user_id"
-
- // GET_KEY
- public const val EXTRA_KEY_ID: String = "key_id"
- public const val EXTRA_MINIMIZE: String = "minimize"
- public const val EXTRA_MINIMIZE_USER_ID: String = "minimize_user_id"
- public const val RESULT_KEY_IDS: String = "key_ids"
-
- // BACKUP
- public const val EXTRA_BACKUP_SECRET: String = "backup_secret"
-
- /* Service Intent returns */
- public const val RESULT_CODE: String = "result_code"
-
- // get actual error object from RESULT_ERROR
- public const val RESULT_CODE_ERROR: Int = 0
-
- // success!
- public const val RESULT_CODE_SUCCESS: Int = 1
-
- // get PendingIntent from RESULT_INTENT, start PendingIntent with startIntentSenderForResult,
- // and execute service method again in onActivityResult
- public const val RESULT_CODE_USER_INTERACTION_REQUIRED: Int = 2
-
- public const val RESULT_ERROR: String = "error"
- public const val RESULT_INTENT: String = "intent"
-
- // DECRYPT_VERIFY
- public const val EXTRA_DETACHED_SIGNATURE: String = "detached_signature"
- public const val EXTRA_PROGRESS_MESSENGER: String = "progress_messenger"
- public const val EXTRA_DATA_LENGTH: String = "data_length"
- public const val EXTRA_DECRYPTION_RESULT: String = "decryption_result"
- public const val EXTRA_SENDER_ADDRESS: String = "sender_address"
- public const val EXTRA_SUPPORT_OVERRIDE_CRYPTO_WARNING: String = "support_override_crpto_warning"
- public const val EXTRA_AUTOCRYPT_PEER_ID: String = "autocrypt_peer_id"
- public const val EXTRA_AUTOCRYPT_PEER_UPDATE: String = "autocrypt_peer_update"
- public const val EXTRA_AUTOCRYPT_PEER_GOSSIP_UPDATES: String = "autocrypt_peer_gossip_updates"
- public const val RESULT_SIGNATURE: String = "signature"
- public const val RESULT_DECRYPTION: String = "decryption"
- public const val RESULT_METADATA: String = "metadata"
- public const val RESULT_INSECURE_DETAIL_INTENT: String = "insecure_detail_intent"
- public const val RESULT_OVERRIDE_CRYPTO_WARNING: String = "override_crypto_warning"
-
- // This will be the charset which was specified in the headers of ascii armored input, if any
- public const val RESULT_CHARSET: String = "charset"
-
- // INTERNAL, must not be used
- internal const val EXTRA_CALL_UUID1 = "call_uuid1"
- internal const val EXTRA_CALL_UUID2 = "call_uuid2"
- }
+ /**
+ * General extras --------------
+ *
+ * required extras: int EXTRA_API_VERSION (always required)
+ *
+ * returned extras: int RESULT_CODE (RESULT_CODE_ERROR, RESULT_CODE_SUCCESS or
+ * RESULT_CODE_USER_INTERACTION_REQUIRED) OpenPgpError RESULT_ERROR (if RESULT_CODE ==
+ * RESULT_CODE_ERROR) PendingIntent RESULT_INTENT (if RESULT_CODE ==
+ * RESULT_CODE_USER_INTERACTION_REQUIRED)
+ */
+ /**
+ * This action performs no operation, but can be used to check if the App has permission to
+ * access the API in general, returning a user interaction PendingIntent otherwise. This can be
+ * used to trigger the permission dialog explicitly.
+ *
+ * This action uses no extras.
+ */
+ public const val ACTION_CHECK_PERMISSION: String = "org.openintents.openpgp.action.CHECK_PERMISSION"
+
+ /**
+ * Sign text resulting in a cleartext signature Some magic pre-processing of the text is done to
+ * convert it to a format usable for cleartext signatures per RFC 4880 before the text is
+ * actually signed:
+ * - end cleartext with newline
+ * - remove whitespaces on line endings
+ *
+ * required extras: long EXTRA_SIGN_KEY_ID (key id of signing key)
+ *
+ * optional extras: char[] EXTRA_PASSPHRASE (key passphrase)
+ */
+ public const val ACTION_CLEARTEXT_SIGN: String = "org.openintents.openpgp.action.CLEARTEXT_SIGN"
+
+ /**
+ * Sign text or binary data resulting in a detached signature. No OutputStream necessary for
+ * ACTION_DETACHED_SIGN (No magic pre-processing like in ACTION_CLEARTEXT_SIGN)! The detached
+ * signature is returned separately in RESULT_DETACHED_SIGNATURE.
+ *
+ * required extras: long EXTRA_SIGN_KEY_ID (key id of signing key)
+ *
+ * optional extras: boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for detached
+ * signature) char[] EXTRA_PASSPHRASE (key passphrase)
+ *
+ * returned extras: byte[] RESULT_DETACHED_SIGNATURE String RESULT_SIGNATURE_MICALG (contains
+ * the name of the used signature algorithm as a string)
+ */
+ public const val ACTION_DETACHED_SIGN: String = "org.openintents.openpgp.action.DETACHED_SIGN"
+
+ /**
+ * Encrypt
+ *
+ * required extras: String[] EXTRA_USER_IDS (=emails of recipients, if more than one key has a
+ * user_id, a PendingIntent is returned via RESULT_INTENT) or long[] EXTRA_KEY_IDS
+ *
+ * optional extras: boolean EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for output) char[]
+ * EXTRA_PASSPHRASE (key passphrase) String EXTRA_ORIGINAL_FILENAME (original filename to be
+ * encrypted as metadata) boolean EXTRA_ENABLE_COMPRESSION (enable ZLIB compression, default ist
+ * true)
+ */
+ public const val ACTION_ENCRYPT: String = "org.openintents.openpgp.action.ENCRYPT"
+
+ /**
+ * Sign and encrypt
+ *
+ * required extras: String[] EXTRA_USER_IDS (=emails of recipients, if more than one key has a
+ * user_id, a PendingIntent is returned via RESULT_INTENT) or long[] EXTRA_KEY_IDS
+ *
+ * optional extras: long EXTRA_SIGN_KEY_ID (key id of signing key) boolean
+ * EXTRA_REQUEST_ASCII_ARMOR (request ascii armor for output) char[] EXTRA_PASSPHRASE (key
+ * passphrase) String EXTRA_ORIGINAL_FILENAME (original filename to be encrypted as metadata)
+ * boolean EXTRA_ENABLE_COMPRESSION (enable ZLIB compression, default ist true)
+ */
+ public const val ACTION_SIGN_AND_ENCRYPT: String = "org.openintents.openpgp.action.SIGN_AND_ENCRYPT"
+
+ public const val ACTION_QUERY_AUTOCRYPT_STATUS: String = "org.openintents.openpgp.action.QUERY_AUTOCRYPT_STATUS"
+
+ /**
+ * Decrypts and verifies given input stream. This methods handles encrypted-only,
+ * signed-and-encrypted, and also signed-only input. OutputStream is optional, e.g., for
+ * verifying detached signatures!
+ *
+ * If OpenPgpSignatureResult.getResult() == OpenPgpSignatureResult.RESULT_KEY_MISSING in
+ * addition a PendingIntent is returned via RESULT_INTENT to download missing keys. On all other
+ * status, in addition a PendingIntent is returned via RESULT_INTENT to open the key view in
+ * OpenKeychain.
+ *
+ * optional extras: byte[] EXTRA_DETACHED_SIGNATURE (detached signature)
+ *
+ * returned extras: OpenPgpSignatureResult RESULT_SIGNATURE OpenPgpDecryptionResult
+ * RESULT_DECRYPTION OpenPgpDecryptMetadata RESULT_METADATA String RESULT_CHARSET (charset which
+ * was specified in the headers of ascii armored input, if any)
+ */
+ public const val ACTION_DECRYPT_VERIFY: String = "org.openintents.openpgp.action.DECRYPT_VERIFY"
+
+ /**
+ * Decrypts the header of an encrypted file to retrieve metadata such as original filename.
+ *
+ * This does not decrypt the actual content of the file.
+ *
+ * returned extras: OpenPgpDecryptMetadata RESULT_METADATA String RESULT_CHARSET (charset which
+ * was specified in the headers of ascii armored input, if any)
+ */
+ public const val ACTION_DECRYPT_METADATA: String = "org.openintents.openpgp.action.DECRYPT_METADATA"
+
+ /**
+ * Select key id for signing
+ *
+ * optional extras: String EXTRA_USER_ID
+ *
+ * returned extras: long EXTRA_SIGN_KEY_ID
+ */
+ public const val ACTION_GET_SIGN_KEY_ID: String = "org.openintents.openpgp.action.GET_SIGN_KEY_ID"
+
+ /**
+ * Get key ids based on given user ids (=emails)
+ *
+ * required extras: String[] EXTRA_USER_IDS
+ *
+ * returned extras: long[] RESULT_KEY_IDS
+ */
+ public const val ACTION_GET_KEY_IDS: String = "org.openintents.openpgp.action.GET_KEY_IDS"
+
+ /**
+ * This action returns RESULT_CODE_SUCCESS if the OpenPGP Provider already has the key
+ * corresponding to the given key id in its database.
+ *
+ * It returns RESULT_CODE_USER_INTERACTION_REQUIRED if the Provider does not have the key. The
+ * PendingIntent from RESULT_INTENT can be used to retrieve those from a keyserver.
+ *
+ * If an Output stream has been defined the whole public key is returned. required extras: long
+ * EXTRA_KEY_ID
+ *
+ * optional extras: String EXTRA_REQUEST_ASCII_ARMOR (request that the returned key is encoded
+ * in ASCII Armor)
+ */
+ public const val ACTION_GET_KEY: String = "org.openintents.openpgp.action.GET_KEY"
+
+ /**
+ * Backup all keys given by EXTRA_KEY_IDS and if requested their secret parts. The encrypted
+ * backup will be written to the OutputStream. The client app has no access to the backup code
+ * used to encrypt the backup! This operation always requires user interaction with
+ * RESULT_CODE_USER_INTERACTION_REQUIRED!
+ *
+ * required extras: long[] EXTRA_KEY_IDS (keys that should be included in the backup) boolean
+ * EXTRA_BACKUP_SECRET (also backup secret keys)
+ */
+ public const val ACTION_BACKUP: String = "org.openintents.openpgp.action.BACKUP"
+
+ public const val ACTION_UPDATE_AUTOCRYPT_PEER: String = "org.openintents.openpgp.action.UPDATE_AUTOCRYPT_PEER"
+
+ /* Intent extras */
+ public const val EXTRA_API_VERSION: String = "api_version"
+
+ // ACTION_DETACHED_SIGN, ENCRYPT, SIGN_AND_ENCRYPT, DECRYPT_VERIFY
+ // request ASCII Armor for output
+ // OpenPGP Radix-64, 33 percent overhead compared to binary, see
+ // http://tools.ietf.org/html/rfc4880#page-53)
+ public const val EXTRA_REQUEST_ASCII_ARMOR: String = "ascii_armor"
+
+ // ACTION_DETACHED_SIGN
+ public const val RESULT_DETACHED_SIGNATURE: String = "detached_signature"
+ public const val RESULT_SIGNATURE_MICALG: String = "signature_micalg"
+
+ // ENCRYPT, SIGN_AND_ENCRYPT, QUERY_AUTOCRYPT_STATUS
+ public const val EXTRA_USER_IDS: String = "user_ids"
+ public const val EXTRA_KEY_IDS: String = "key_ids"
+ public const val EXTRA_KEY_IDS_SELECTED: String = "key_ids_selected"
+ public const val EXTRA_SIGN_KEY_ID: String = "sign_key_id"
+
+ public const val RESULT_KEYS_CONFIRMED: String = "keys_confirmed"
+ public const val RESULT_AUTOCRYPT_STATUS: String = "autocrypt_status"
+ public const val AUTOCRYPT_STATUS_UNAVAILABLE: Int = 0
+ public const val AUTOCRYPT_STATUS_DISCOURAGE: Int = 1
+ public const val AUTOCRYPT_STATUS_AVAILABLE: Int = 2
+ public const val AUTOCRYPT_STATUS_MUTUAL: Int = 3
+
+ // optional extras:
+ public const val EXTRA_PASSPHRASE: String = "passphrase"
+ public const val EXTRA_ORIGINAL_FILENAME: String = "original_filename"
+ public const val EXTRA_ENABLE_COMPRESSION: String = "enable_compression"
+ public const val EXTRA_OPPORTUNISTIC_ENCRYPTION: String = "opportunistic"
+
+ // GET_SIGN_KEY_ID
+ public const val EXTRA_USER_ID: String = "user_id"
+
+ // GET_KEY
+ public const val EXTRA_KEY_ID: String = "key_id"
+ public const val EXTRA_MINIMIZE: String = "minimize"
+ public const val EXTRA_MINIMIZE_USER_ID: String = "minimize_user_id"
+ public const val RESULT_KEY_IDS: String = "key_ids"
+
+ // BACKUP
+ public const val EXTRA_BACKUP_SECRET: String = "backup_secret"
+
+ /* Service Intent returns */
+ public const val RESULT_CODE: String = "result_code"
+
+ // get actual error object from RESULT_ERROR
+ public const val RESULT_CODE_ERROR: Int = 0
+
+ // success!
+ public const val RESULT_CODE_SUCCESS: Int = 1
+
+ // get PendingIntent from RESULT_INTENT, start PendingIntent with
+ // startIntentSenderForResult,
+ // and execute service method again in onActivityResult
+ public const val RESULT_CODE_USER_INTERACTION_REQUIRED: Int = 2
+
+ public const val RESULT_ERROR: String = "error"
+ public const val RESULT_INTENT: String = "intent"
+
+ // DECRYPT_VERIFY
+ public const val EXTRA_DETACHED_SIGNATURE: String = "detached_signature"
+ public const val EXTRA_PROGRESS_MESSENGER: String = "progress_messenger"
+ public const val EXTRA_DATA_LENGTH: String = "data_length"
+ public const val EXTRA_DECRYPTION_RESULT: String = "decryption_result"
+ public const val EXTRA_SENDER_ADDRESS: String = "sender_address"
+ public const val EXTRA_SUPPORT_OVERRIDE_CRYPTO_WARNING: String = "support_override_crpto_warning"
+ public const val EXTRA_AUTOCRYPT_PEER_ID: String = "autocrypt_peer_id"
+ public const val EXTRA_AUTOCRYPT_PEER_UPDATE: String = "autocrypt_peer_update"
+ public const val EXTRA_AUTOCRYPT_PEER_GOSSIP_UPDATES: String = "autocrypt_peer_gossip_updates"
+ public const val RESULT_SIGNATURE: String = "signature"
+ public const val RESULT_DECRYPTION: String = "decryption"
+ public const val RESULT_METADATA: String = "metadata"
+ public const val RESULT_INSECURE_DETAIL_INTENT: String = "insecure_detail_intent"
+ public const val RESULT_OVERRIDE_CRYPTO_WARNING: String = "override_crypto_warning"
+
+ // This will be the charset which was specified in the headers of ascii armored input, if
+ // any
+ public const val RESULT_CHARSET: String = "charset"
+
+ // INTERNAL, must not be used
+ internal const val EXTRA_CALL_UUID1 = "call_uuid1"
+ internal const val EXTRA_CALL_UUID2 = "call_uuid2"
+ }
}
diff --git a/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpServiceConnection.kt b/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpServiceConnection.kt
index 4efa6a9d..6a0a9933 100644
--- a/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpServiceConnection.kt
+++ b/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpServiceConnection.kt
@@ -13,76 +13,72 @@ import org.openintents.openpgp.IOpenPgpService2
public class OpenPgpServiceConnection(context: Context, providerPackageName: String?) {
- // callback interface
- public interface OnBound {
+ // callback interface
+ public interface OnBound {
- public fun onBound(service: IOpenPgpService2)
- public fun onError(e: Exception)
- }
+ public fun onBound(service: IOpenPgpService2)
+ public fun onError(e: Exception)
+ }
- private val mApplicationContext: Context = context.applicationContext
- public var service: IOpenPgpService2? = null
- private set
- private val mProviderPackageName: String? = providerPackageName
- private var mOnBoundListener: OnBound? = null
+ private val mApplicationContext: Context = context.applicationContext
+ public var service: IOpenPgpService2? = null
+ private set
+ private val mProviderPackageName: String? = providerPackageName
+ private var mOnBoundListener: OnBound? = null
- /**
- * Create new connection with callback
- *
- * @param context
- * @param providerPackageName specify package name of OpenPGP provider,
- * e.g., "org.sufficientlysecure.keychain"
- * @param onBoundListener callback, executed when connection to service has been established
- */
- public constructor(
- context: Context,
- providerPackageName: String?,
- onBoundListener: OnBound?
- ) : this(context, providerPackageName) {
- mOnBoundListener = onBoundListener
- }
+ /**
+ * Create new connection with callback
+ *
+ * @param context
+ * @param providerPackageName specify package name of OpenPGP provider, e.g.,
+ * "org.sufficientlysecure.keychain"
+ * @param onBoundListener callback, executed when connection to service has been established
+ */
+ public constructor(
+ context: Context,
+ providerPackageName: String?,
+ onBoundListener: OnBound?
+ ) : this(context, providerPackageName) {
+ mOnBoundListener = onBoundListener
+ }
- public val isBound: Boolean
- get() = service != null
+ public val isBound: Boolean
+ get() = service != null
- private val mServiceConnection: ServiceConnection = object : ServiceConnection {
- override fun onServiceConnected(name: ComponentName, service: IBinder) {
- this@OpenPgpServiceConnection.service = IOpenPgpService2.Stub.asInterface(service)
- mOnBoundListener?.onBound(this@OpenPgpServiceConnection.service!!)
- }
+ private val mServiceConnection: ServiceConnection =
+ object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName, service: IBinder) {
+ this@OpenPgpServiceConnection.service = IOpenPgpService2.Stub.asInterface(service)
+ mOnBoundListener?.onBound(this@OpenPgpServiceConnection.service!!)
+ }
- override fun onServiceDisconnected(name: ComponentName) {
- service = null
- }
+ override fun onServiceDisconnected(name: ComponentName) {
+ service = null
+ }
}
- /**
- * If not already bound, bind to service!
- */
- public fun bindToService() {
- if (service == null) {
- // if not already bound...
- try {
- val serviceIntent = Intent(OpenPgpApi.SERVICE_INTENT_2)
- // NOTE: setPackage is very important to restrict the intent to this provider only!
- serviceIntent.setPackage(mProviderPackageName)
- val connect = mApplicationContext.bindService(
- serviceIntent, mServiceConnection,
- Context.BIND_AUTO_CREATE
- )
- if (!connect) {
- throw Exception("bindService() returned false!")
- }
- } catch (e: Exception) {
- mOnBoundListener?.onError(e)
- }
- } else {
- // already bound, but also inform client about it with callback
- mOnBoundListener?.onBound(service!!)
+ /** If not already bound, bind to service! */
+ public fun bindToService() {
+ if (service == null) {
+ // if not already bound...
+ try {
+ val serviceIntent = Intent(OpenPgpApi.SERVICE_INTENT_2)
+ // NOTE: setPackage is very important to restrict the intent to this provider only!
+ serviceIntent.setPackage(mProviderPackageName)
+ val connect = mApplicationContext.bindService(serviceIntent, mServiceConnection, Context.BIND_AUTO_CREATE)
+ if (!connect) {
+ throw Exception("bindService() returned false!")
}
+ } catch (e: Exception) {
+ mOnBoundListener?.onError(e)
+ }
+ } else {
+ // already bound, but also inform client about it with callback
+ mOnBoundListener?.onBound(service!!)
}
+ }
- public fun unbindFromService() {
- mApplicationContext.unbindService(mServiceConnection)
- }
+ public fun unbindFromService() {
+ mApplicationContext.unbindService(mServiceConnection)
+ }
}
diff --git a/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpUtils.kt b/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpUtils.kt
index 03ee11fc..f277615e 100644
--- a/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpUtils.kt
+++ b/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/OpenPgpUtils.kt
@@ -12,103 +12,96 @@ import java.util.regex.Pattern
public object OpenPgpUtils {
- private val PGP_MESSAGE: Pattern = Pattern.compile(
- ".*?(-----BEGIN PGP MESSAGE-----.*?-----END PGP MESSAGE-----).*",
- Pattern.DOTALL
+ private val PGP_MESSAGE: Pattern =
+ Pattern.compile(".*?(-----BEGIN PGP MESSAGE-----.*?-----END PGP MESSAGE-----).*", Pattern.DOTALL)
+ private val PGP_SIGNED_MESSAGE: Pattern =
+ Pattern.compile(
+ ".*?(-----BEGIN PGP SIGNED MESSAGE-----.*?-----BEGIN PGP SIGNATURE-----.*?-----END PGP SIGNATURE-----).*",
+ Pattern.DOTALL
)
- private val PGP_SIGNED_MESSAGE: Pattern = Pattern.compile(
- ".*?(-----BEGIN PGP SIGNED MESSAGE-----.*?-----BEGIN PGP SIGNATURE-----.*?-----END PGP SIGNATURE-----).*",
- Pattern.DOTALL
- )
- private val USER_ID_PATTERN = Pattern.compile("^(.*?)(?: \\((.*)\\))?(?: <(.*)>)?$")
- private val EMAIL_PATTERN = Pattern.compile("^<?\"?([^<>\"]*@[^<>\"]*\\.[^<>\"]*)\"?>?$")
- public const val PARSE_RESULT_NO_PGP: Int = -1
- public const val PARSE_RESULT_MESSAGE: Int = 0
- public const val PARSE_RESULT_SIGNED_MESSAGE: Int = 1
+ private val USER_ID_PATTERN = Pattern.compile("^(.*?)(?: \\((.*)\\))?(?: <(.*)>)?$")
+ private val EMAIL_PATTERN = Pattern.compile("^<?\"?([^<>\"]*@[^<>\"]*\\.[^<>\"]*)\"?>?$")
+ public const val PARSE_RESULT_NO_PGP: Int = -1
+ public const val PARSE_RESULT_MESSAGE: Int = 0
+ public const val PARSE_RESULT_SIGNED_MESSAGE: Int = 1
- public fun parseMessage(message: String): Int {
- val matcherSigned = PGP_SIGNED_MESSAGE.matcher(message)
- val matcherMessage = PGP_MESSAGE.matcher(message)
- return when {
- matcherMessage.matches() -> PARSE_RESULT_MESSAGE
- matcherSigned.matches() -> PARSE_RESULT_SIGNED_MESSAGE
- else -> PARSE_RESULT_NO_PGP
- }
+ public fun parseMessage(message: String): Int {
+ val matcherSigned = PGP_SIGNED_MESSAGE.matcher(message)
+ val matcherMessage = PGP_MESSAGE.matcher(message)
+ return when {
+ matcherMessage.matches() -> PARSE_RESULT_MESSAGE
+ matcherSigned.matches() -> PARSE_RESULT_SIGNED_MESSAGE
+ else -> PARSE_RESULT_NO_PGP
}
+ }
- public fun isAvailable(context: Context): Boolean {
- val intent = Intent(OpenPgpApi.SERVICE_INTENT_2)
- val resInfo =
- context.packageManager.queryIntentServices(intent, 0)
- return resInfo.isNotEmpty()
- }
+ public fun isAvailable(context: Context): Boolean {
+ val intent = Intent(OpenPgpApi.SERVICE_INTENT_2)
+ val resInfo = context.packageManager.queryIntentServices(intent, 0)
+ return resInfo.isNotEmpty()
+ }
- public fun convertKeyIdToHex(keyId: Long): String {
- return "0x" + convertKeyIdToHex32bit(keyId shr 32) + convertKeyIdToHex32bit(
- keyId
- )
- }
+ public fun convertKeyIdToHex(keyId: Long): String {
+ return "0x" + convertKeyIdToHex32bit(keyId shr 32) + convertKeyIdToHex32bit(keyId)
+ }
- private fun convertKeyIdToHex32bit(keyId: Long): String {
- var hexString =
- java.lang.Long.toHexString(keyId and 0xffffffffL).toLowerCase(Locale.ENGLISH)
- while (hexString.length < 8) {
- hexString = "0$hexString"
- }
- return hexString
+ private fun convertKeyIdToHex32bit(keyId: Long): String {
+ var hexString = java.lang.Long.toHexString(keyId and 0xffffffffL).toLowerCase(Locale.ENGLISH)
+ while (hexString.length < 8) {
+ hexString = "0$hexString"
}
+ return hexString
+ }
- /**
- * Splits userId string into naming part, email part, and comment part.
- * See SplitUserIdTest for examples.
- */
- public fun splitUserId(userId: String): UserId {
- if (userId.isNotEmpty()) {
- val matcher = USER_ID_PATTERN.matcher(userId)
- if (matcher.matches()) {
- var name = if (matcher.group(1).isEmpty()) null else matcher.group(1)
- val comment = matcher.group(2)
- var email = matcher.group(3)
- if (email != null && name != null) {
- val emailMatcher = EMAIL_PATTERN.matcher(name)
- if (emailMatcher.matches() && email == emailMatcher.group(1)) {
- email = emailMatcher.group(1)
- name = null
- }
- }
- if (email == null && name != null) {
- val emailMatcher = EMAIL_PATTERN.matcher(name)
- if (emailMatcher.matches()) {
- email = emailMatcher.group(1)
- name = null
- }
- }
- return UserId(name, email, comment)
- }
+ /**
+ * Splits userId string into naming part, email part, and comment part. See SplitUserIdTest for
+ * examples.
+ */
+ public fun splitUserId(userId: String): UserId {
+ if (userId.isNotEmpty()) {
+ val matcher = USER_ID_PATTERN.matcher(userId)
+ if (matcher.matches()) {
+ var name = if (matcher.group(1).isEmpty()) null else matcher.group(1)
+ val comment = matcher.group(2)
+ var email = matcher.group(3)
+ if (email != null && name != null) {
+ val emailMatcher = EMAIL_PATTERN.matcher(name)
+ if (emailMatcher.matches() && email == emailMatcher.group(1)) {
+ email = emailMatcher.group(1)
+ name = null
+ }
+ }
+ if (email == null && name != null) {
+ val emailMatcher = EMAIL_PATTERN.matcher(name)
+ if (emailMatcher.matches()) {
+ email = emailMatcher.group(1)
+ name = null
+ }
}
- return UserId(null, null, null)
+ return UserId(name, email, comment)
+ }
}
+ return UserId(null, null, null)
+ }
- /**
- * Returns a composed user id. Returns null if name, email and comment are empty.
- */
- public fun createUserId(userId: UserId): String? {
- val userIdBuilder = StringBuilder()
- if (!userId.name.isNullOrEmpty()) {
- userIdBuilder.append(userId.name)
- }
- if (!userId.comment.isNullOrEmpty()) {
- userIdBuilder.append(" (")
- userIdBuilder.append(userId.comment)
- userIdBuilder.append(")")
- }
- if (!userId.email.isNullOrEmpty()) {
- userIdBuilder.append(" <")
- userIdBuilder.append(userId.email)
- userIdBuilder.append(">")
- }
- return if (userIdBuilder.isEmpty()) null else userIdBuilder.toString()
+ /** Returns a composed user id. Returns null if name, email and comment are empty. */
+ public fun createUserId(userId: UserId): String? {
+ val userIdBuilder = StringBuilder()
+ if (!userId.name.isNullOrEmpty()) {
+ userIdBuilder.append(userId.name)
+ }
+ if (!userId.comment.isNullOrEmpty()) {
+ userIdBuilder.append(" (")
+ userIdBuilder.append(userId.comment)
+ userIdBuilder.append(")")
+ }
+ if (!userId.email.isNullOrEmpty()) {
+ userIdBuilder.append(" <")
+ userIdBuilder.append(userId.email)
+ userIdBuilder.append(">")
}
+ return if (userIdBuilder.isEmpty()) null else userIdBuilder.toString()
+ }
- public class UserId(public val name: String?, public val email: String?, public val comment: String?) : Serializable
+ public class UserId(public val name: String?, public val email: String?, public val comment: String?) : Serializable
}
diff --git a/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/ParcelFileDescriptorUtil.kt b/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/ParcelFileDescriptorUtil.kt
index 58657118..26e04e97 100644
--- a/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/ParcelFileDescriptorUtil.kt
+++ b/openpgp-ktx/src/main/java/me/msfjarvis/openpgpktx/util/ParcelFileDescriptorUtil.kt
@@ -14,50 +14,47 @@ import java.io.OutputStream
internal object ParcelFileDescriptorUtil {
- private const val TAG = "PFDUtils"
-
- @Throws(IOException::class)
- internal fun pipeFrom(inputStream: InputStream): ParcelFileDescriptor {
- val pipe = ParcelFileDescriptor.createPipe()
- val readSide = pipe[0]
- val writeSide = pipe[1]
- TransferThread(inputStream, AutoCloseOutputStream(writeSide))
- .start()
- return readSide
- }
-
- @Throws(IOException::class)
- internal fun pipeTo(outputStream: OutputStream, output: ParcelFileDescriptor?): TransferThread {
- val t = TransferThread(AutoCloseInputStream(output), outputStream)
- t.start()
- return t
- }
-
- internal class TransferThread(val `in`: InputStream, private val out: OutputStream) : Thread("IPC Transfer Thread") {
-
- override fun run() {
- val buf = ByteArray(4096)
- var len: Int
- try {
- while (`in`.read(buf).also { len = it } > 0) {
- out.write(buf, 0, len)
- }
- } catch (e: IOException) {
- Log.e(TAG, "IOException when writing to out", e)
- } finally {
- try {
- `in`.close()
- } catch (ignored: IOException) {
- }
- try {
- out.close()
- } catch (ignored: IOException) {
- }
- }
+ private const val TAG = "PFDUtils"
+
+ @Throws(IOException::class)
+ internal fun pipeFrom(inputStream: InputStream): ParcelFileDescriptor {
+ val pipe = ParcelFileDescriptor.createPipe()
+ val readSide = pipe[0]
+ val writeSide = pipe[1]
+ TransferThread(inputStream, AutoCloseOutputStream(writeSide)).start()
+ return readSide
+ }
+
+ @Throws(IOException::class)
+ internal fun pipeTo(outputStream: OutputStream, output: ParcelFileDescriptor?): TransferThread {
+ val t = TransferThread(AutoCloseInputStream(output), outputStream)
+ t.start()
+ return t
+ }
+
+ internal class TransferThread(val `in`: InputStream, private val out: OutputStream) : Thread("IPC Transfer Thread") {
+
+ override fun run() {
+ val buf = ByteArray(4096)
+ var len: Int
+ try {
+ while (`in`.read(buf).also { len = it } > 0) {
+ out.write(buf, 0, len)
}
+ } catch (e: IOException) {
+ Log.e(TAG, "IOException when writing to out", e)
+ } finally {
+ try {
+ `in`.close()
+ } catch (ignored: IOException) {}
+ try {
+ out.close()
+ } catch (ignored: IOException) {}
+ }
+ }
- init {
- isDaemon = true
- }
+ init {
+ isDaemon = true
}
+ }
}
diff --git a/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpDecryptionResult.kt b/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpDecryptionResult.kt
index ea9c043a..058afa42 100644
--- a/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpDecryptionResult.kt
+++ b/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpDecryptionResult.kt
@@ -12,115 +12,109 @@ import android.os.Parcelable.Creator
public class OpenPgpDecryptionResult() : Parcelable {
- private var result = 0
- private var sessionKey: ByteArray? = null
- private var decryptedSessionKey: ByteArray? = null
-
- private constructor(result: Int) : this() {
- this.result = result
- sessionKey = null
- decryptedSessionKey = null
+ private var result = 0
+ private var sessionKey: ByteArray? = null
+ private var decryptedSessionKey: ByteArray? = null
+
+ private constructor(result: Int) : this() {
+ this.result = result
+ sessionKey = null
+ decryptedSessionKey = null
+ }
+
+ private constructor(result: Int, sessionKey: ByteArray?, decryptedSessionKey: ByteArray?) : this() {
+ this.result = result
+ if (sessionKey == null != (decryptedSessionKey == null)) {
+ throw AssertionError("sessionkey must be null iff decryptedSessionKey is null")
}
-
- private constructor(
- result: Int,
- sessionKey: ByteArray?,
- decryptedSessionKey: ByteArray?
- ) : this() {
- this.result = result
- if (sessionKey == null != (decryptedSessionKey == null)) {
- throw AssertionError("sessionkey must be null iff decryptedSessionKey is null")
- }
- this.sessionKey = sessionKey
- this.decryptedSessionKey = decryptedSessionKey
- }
-
- public fun getResult(): Int {
- return result
- }
-
- public fun hasDecryptedSessionKey(): Boolean {
- return sessionKey != null
- }
-
- public fun getSessionKey(): ByteArray? {
- return if (sessionKey == null) {
- null
- } else sessionKey!!.copyOf(sessionKey!!.size)
- }
-
- public fun getDecryptedSessionKey(): ByteArray? {
- return if (sessionKey == null || decryptedSessionKey == null) {
- null
- } else decryptedSessionKey!!.copyOf(decryptedSessionKey!!.size)
- }
-
- override fun describeContents(): Int {
- return 0
- }
-
- override fun writeToParcel(dest: Parcel, flags: Int) {
- /**
- * NOTE: When adding fields in the process of updating this API, make sure to bump
- * [.PARCELABLE_VERSION].
- */
- dest.writeInt(PARCELABLE_VERSION)
- // Inject a placeholder that will store the parcel size from this point on
- // (not including the size itself).
- val sizePosition = dest.dataPosition()
- dest.writeInt(0)
- val startPosition = dest.dataPosition()
- // version 1
- dest.writeInt(result)
- // version 2
- dest.writeByteArray(sessionKey)
- dest.writeByteArray(decryptedSessionKey)
- // Go back and write the size
- val parcelableSize = dest.dataPosition() - startPosition
- dest.setDataPosition(sizePosition)
- dest.writeInt(parcelableSize)
- dest.setDataPosition(startPosition + parcelableSize)
- }
-
- override fun toString(): String {
- return "\nresult: $result"
+ this.sessionKey = sessionKey
+ this.decryptedSessionKey = decryptedSessionKey
+ }
+
+ public fun getResult(): Int {
+ return result
+ }
+
+ public fun hasDecryptedSessionKey(): Boolean {
+ return sessionKey != null
+ }
+
+ public fun getSessionKey(): ByteArray? {
+ return if (sessionKey == null) {
+ null
+ } else sessionKey!!.copyOf(sessionKey!!.size)
+ }
+
+ public fun getDecryptedSessionKey(): ByteArray? {
+ return if (sessionKey == null || decryptedSessionKey == null) {
+ null
+ } else decryptedSessionKey!!.copyOf(decryptedSessionKey!!.size)
+ }
+
+ override fun describeContents(): Int {
+ return 0
+ }
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ /**
+ * NOTE: When adding fields in the process of updating this API, make sure to bump
+ * [.PARCELABLE_VERSION].
+ */
+ dest.writeInt(PARCELABLE_VERSION)
+ // Inject a placeholder that will store the parcel size from this point on
+ // (not including the size itself).
+ val sizePosition = dest.dataPosition()
+ dest.writeInt(0)
+ val startPosition = dest.dataPosition()
+ // version 1
+ dest.writeInt(result)
+ // version 2
+ dest.writeByteArray(sessionKey)
+ dest.writeByteArray(decryptedSessionKey)
+ // Go back and write the size
+ val parcelableSize = dest.dataPosition() - startPosition
+ dest.setDataPosition(sizePosition)
+ dest.writeInt(parcelableSize)
+ dest.setDataPosition(startPosition + parcelableSize)
+ }
+
+ override fun toString(): String {
+ return "\nresult: $result"
+ }
+
+ public companion object CREATOR : Creator<OpenPgpDecryptionResult> {
+
+ /**
+ * Since there might be a case where new versions of the client using the library getting old
+ * versions of the protocol (and thus old versions of this class), we need a versioning system
+ * for the parcels sent between the clients and the providers.
+ */
+ private const val PARCELABLE_VERSION = 2
+
+ // content not encrypted
+ public const val RESULT_NOT_ENCRYPTED: Int = -1
+
+ // insecure!
+ public const val RESULT_INSECURE: Int = 0
+
+ // encrypted
+ public const val RESULT_ENCRYPTED: Int = 1
+
+ override fun createFromParcel(source: Parcel): OpenPgpDecryptionResult? {
+ val version = source.readInt() // parcelableVersion
+ val parcelableSize = source.readInt()
+ val startPosition = source.dataPosition()
+ val result = source.readInt()
+ val sessionKey = if (version > 1) source.createByteArray() else null
+ val decryptedSessionKey = if (version > 1) source.createByteArray() else null
+ val vr = OpenPgpDecryptionResult(result, sessionKey, decryptedSessionKey)
+ // skip over all fields added in future versions of this parcel
+ source.setDataPosition(startPosition + parcelableSize)
+ return vr
}
- public companion object CREATOR : Creator<OpenPgpDecryptionResult> {
-
- /**
- * Since there might be a case where new versions of the client using the library getting
- * old versions of the protocol (and thus old versions of this class), we need a versioning
- * system for the parcels sent between the clients and the providers.
- */
- private const val PARCELABLE_VERSION = 2
-
- // content not encrypted
- public const val RESULT_NOT_ENCRYPTED: Int = -1
-
- // insecure!
- public const val RESULT_INSECURE: Int = 0
-
- // encrypted
- public const val RESULT_ENCRYPTED: Int = 1
-
- override fun createFromParcel(source: Parcel): OpenPgpDecryptionResult? {
- val version = source.readInt() // parcelableVersion
- val parcelableSize = source.readInt()
- val startPosition = source.dataPosition()
- val result = source.readInt()
- val sessionKey = if (version > 1) source.createByteArray() else null
- val decryptedSessionKey =
- if (version > 1) source.createByteArray() else null
- val vr =
- OpenPgpDecryptionResult(result, sessionKey, decryptedSessionKey)
- // skip over all fields added in future versions of this parcel
- source.setDataPosition(startPosition + parcelableSize)
- return vr
- }
-
- override fun newArray(size: Int): Array<OpenPgpDecryptionResult?> {
- return arrayOfNulls(size)
- }
+ override fun newArray(size: Int): Array<OpenPgpDecryptionResult?> {
+ return arrayOfNulls(size)
}
+ }
}
diff --git a/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpError.kt b/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpError.kt
index 30d64b16..fb1c9602 100644
--- a/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpError.kt
+++ b/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpError.kt
@@ -12,80 +12,80 @@ import android.os.Parcelable.Creator
public class OpenPgpError() : Parcelable {
- public var errorId: Int = 0
- public var message: String? = null
+ public var errorId: Int = 0
+ public var message: String? = null
- private constructor(parcel: Parcel) : this() {
- errorId = parcel.readInt()
- message = parcel.readString()
- }
+ private constructor(parcel: Parcel) : this() {
+ errorId = parcel.readInt()
+ message = parcel.readString()
+ }
- internal constructor(errorId: Int, message: String?) : this() {
- this.errorId = errorId
- this.message = message
- }
+ internal constructor(errorId: Int, message: String?) : this() {
+ this.errorId = errorId
+ this.message = message
+ }
- internal constructor(b: OpenPgpError) : this() {
- errorId = b.errorId
- message = b.message
- }
+ internal constructor(b: OpenPgpError) : this() {
+ errorId = b.errorId
+ message = b.message
+ }
- override fun describeContents(): Int {
- return 0
- }
+ override fun describeContents(): Int {
+ return 0
+ }
- override fun writeToParcel(dest: Parcel, flags: Int) {
- /**
- * NOTE: When adding fields in the process of updating this API, make sure to bump
- * [PARCELABLE_VERSION].
- */
- dest.writeInt(PARCELABLE_VERSION)
- // Inject a placeholder that will store the parcel size from this point on
- // (not including the size itself).
- val sizePosition = dest.dataPosition()
- dest.writeInt(0)
- val startPosition = dest.dataPosition()
- // version 1
- dest.writeInt(errorId)
- dest.writeString(message)
- // Go back and write the size
- val parcelableSize = dest.dataPosition() - startPosition
- dest.setDataPosition(sizePosition)
- dest.writeInt(parcelableSize)
- dest.setDataPosition(startPosition + parcelableSize)
- }
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ /**
+ * NOTE: When adding fields in the process of updating this API, make sure to bump
+ * [PARCELABLE_VERSION].
+ */
+ dest.writeInt(PARCELABLE_VERSION)
+ // Inject a placeholder that will store the parcel size from this point on
+ // (not including the size itself).
+ val sizePosition = dest.dataPosition()
+ dest.writeInt(0)
+ val startPosition = dest.dataPosition()
+ // version 1
+ dest.writeInt(errorId)
+ dest.writeString(message)
+ // Go back and write the size
+ val parcelableSize = dest.dataPosition() - startPosition
+ dest.setDataPosition(sizePosition)
+ dest.writeInt(parcelableSize)
+ dest.setDataPosition(startPosition + parcelableSize)
+ }
- public companion object CREATOR : Creator<OpenPgpError> {
+ public companion object CREATOR : Creator<OpenPgpError> {
- /**
- * Since there might be a case where new versions of the client using the library getting
- * old versions of the protocol (and thus old versions of this class), we need a versioning
- * system for the parcels sent between the clients and the providers.
- */
- private const val PARCELABLE_VERSION = 1
+ /**
+ * Since there might be a case where new versions of the client using the library getting old
+ * versions of the protocol (and thus old versions of this class), we need a versioning system
+ * for the parcels sent between the clients and the providers.
+ */
+ private const val PARCELABLE_VERSION = 1
- // possible values for errorId
- public const val CLIENT_SIDE_ERROR: Int = -1
- public const val GENERIC_ERROR: Int = 0
- public const val INCOMPATIBLE_API_VERSIONS: Int = 1
- public const val NO_OR_WRONG_PASSPHRASE: Int = 2
- public const val NO_USER_IDS: Int = 3
- public const val OPPORTUNISTIC_MISSING_KEYS: Int = 4
+ // possible values for errorId
+ public const val CLIENT_SIDE_ERROR: Int = -1
+ public const val GENERIC_ERROR: Int = 0
+ public const val INCOMPATIBLE_API_VERSIONS: Int = 1
+ public const val NO_OR_WRONG_PASSPHRASE: Int = 2
+ public const val NO_USER_IDS: Int = 3
+ public const val OPPORTUNISTIC_MISSING_KEYS: Int = 4
- override fun createFromParcel(source: Parcel): OpenPgpError? {
- source.readInt() // parcelableVersion
- val parcelableSize = source.readInt()
- val startPosition = source.dataPosition()
- val error = OpenPgpError()
- error.errorId = source.readInt()
- error.message = source.readString()
- // skip over all fields added in future versions of this parcel
- source.setDataPosition(startPosition + parcelableSize)
- return error
- }
+ override fun createFromParcel(source: Parcel): OpenPgpError? {
+ source.readInt() // parcelableVersion
+ val parcelableSize = source.readInt()
+ val startPosition = source.dataPosition()
+ val error = OpenPgpError()
+ error.errorId = source.readInt()
+ error.message = source.readString()
+ // skip over all fields added in future versions of this parcel
+ source.setDataPosition(startPosition + parcelableSize)
+ return error
+ }
- override fun newArray(size: Int): Array<OpenPgpError?> {
- return arrayOfNulls(size)
- }
+ override fun newArray(size: Int): Array<OpenPgpError?> {
+ return arrayOfNulls(size)
}
+ }
}
diff --git a/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpMetadata.kt b/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpMetadata.kt
index 81b200e1..b590456a 100644
--- a/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpMetadata.kt
+++ b/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpMetadata.kt
@@ -12,111 +12,106 @@ import android.os.Parcelable.Creator
public class OpenPgpMetadata() : Parcelable {
- public var filename: String? = null
- public var mimeType: String? = null
- public var charset: String? = null
- public var modificationTime: Long = 0
- public var originalSize: Long = 0
+ public var filename: String? = null
+ public var mimeType: String? = null
+ public var charset: String? = null
+ public var modificationTime: Long = 0
+ public var originalSize: Long = 0
- private constructor(
- filename: String?,
- mimeType: String?,
- modificationTime: Long,
- originalSize: Long,
- charset: String?
- ) : this() {
- this.filename = filename
- this.mimeType = mimeType
- this.modificationTime = modificationTime
- this.originalSize = originalSize
- this.charset = charset
- }
+ private constructor(
+ filename: String?,
+ mimeType: String?,
+ modificationTime: Long,
+ originalSize: Long,
+ charset: String?
+ ) : this() {
+ this.filename = filename
+ this.mimeType = mimeType
+ this.modificationTime = modificationTime
+ this.originalSize = originalSize
+ this.charset = charset
+ }
- private constructor(
- filename: String?,
- mimeType: String?,
- modificationTime: Long,
- originalSize: Long
- ) : this() {
- this.filename = filename
- this.mimeType = mimeType
- this.modificationTime = modificationTime
- this.originalSize = originalSize
- }
+ private constructor(filename: String?, mimeType: String?, modificationTime: Long, originalSize: Long) : this() {
+ this.filename = filename
+ this.mimeType = mimeType
+ this.modificationTime = modificationTime
+ this.originalSize = originalSize
+ }
- private constructor(b: OpenPgpMetadata) : this() {
- filename = b.filename
- mimeType = b.mimeType
- modificationTime = b.modificationTime
- originalSize = b.originalSize
- }
+ private constructor(b: OpenPgpMetadata) : this() {
+ filename = b.filename
+ mimeType = b.mimeType
+ modificationTime = b.modificationTime
+ originalSize = b.originalSize
+ }
- override fun describeContents(): Int {
- return 0
- }
+ override fun describeContents(): Int {
+ return 0
+ }
- override fun writeToParcel(dest: Parcel, flags: Int) {
- /**
- * NOTE: When adding fields in the process of updating this API, make sure to bump
- * [PARCELABLE_VERSION].
- */
- dest.writeInt(PARCELABLE_VERSION)
- // Inject a placeholder that will store the parcel size from this point on
- // (not including the size itself).
- val sizePosition = dest.dataPosition()
- dest.writeInt(0)
- val startPosition = dest.dataPosition()
- // version 1
- dest.writeString(filename)
- dest.writeString(mimeType)
- dest.writeLong(modificationTime)
- dest.writeLong(originalSize)
- // version 2
- dest.writeString(charset)
- // Go back and write the size
- val parcelableSize = dest.dataPosition() - startPosition
- dest.setDataPosition(sizePosition)
- dest.writeInt(parcelableSize)
- dest.setDataPosition(startPosition + parcelableSize)
- }
-
- public companion object CREATOR : Creator<OpenPgpMetadata> {
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ /**
+ * NOTE: When adding fields in the process of updating this API, make sure to bump
+ * [PARCELABLE_VERSION].
+ */
+ dest.writeInt(PARCELABLE_VERSION)
+ // Inject a placeholder that will store the parcel size from this point on
+ // (not including the size itself).
+ val sizePosition = dest.dataPosition()
+ dest.writeInt(0)
+ val startPosition = dest.dataPosition()
+ // version 1
+ dest.writeString(filename)
+ dest.writeString(mimeType)
+ dest.writeLong(modificationTime)
+ dest.writeLong(originalSize)
+ // version 2
+ dest.writeString(charset)
+ // Go back and write the size
+ val parcelableSize = dest.dataPosition() - startPosition
+ dest.setDataPosition(sizePosition)
+ dest.writeInt(parcelableSize)
+ dest.setDataPosition(startPosition + parcelableSize)
+ }
- /**
- * Since there might be a case where new versions of the client using the library getting
- * old versions of the protocol (and thus old versions of this class), we need a versioning
- * system for the parcels sent between the clients and the providers.
- */
- private const val PARCELABLE_VERSION = 2
+ public companion object CREATOR : Creator<OpenPgpMetadata> {
- override fun createFromParcel(source: Parcel): OpenPgpMetadata? {
- val version = source.readInt() // parcelableVersion
- val parcelableSize = source.readInt()
- val startPosition = source.dataPosition()
- val vr = OpenPgpMetadata()
- vr.filename = source.readString()
- vr.mimeType = source.readString()
- vr.modificationTime = source.readLong()
- vr.originalSize = source.readLong()
- if (version >= 2) {
- vr.charset = source.readString()
- }
- // skip over all fields added in future versions of this parcel
- source.setDataPosition(startPosition + parcelableSize)
- return vr
- }
+ /**
+ * Since there might be a case where new versions of the client using the library getting old
+ * versions of the protocol (and thus old versions of this class), we need a versioning system
+ * for the parcels sent between the clients and the providers.
+ */
+ private const val PARCELABLE_VERSION = 2
- override fun newArray(size: Int): Array<OpenPgpMetadata?> {
- return arrayOfNulls(size)
- }
+ override fun createFromParcel(source: Parcel): OpenPgpMetadata? {
+ val version = source.readInt() // parcelableVersion
+ val parcelableSize = source.readInt()
+ val startPosition = source.dataPosition()
+ val vr = OpenPgpMetadata()
+ vr.filename = source.readString()
+ vr.mimeType = source.readString()
+ vr.modificationTime = source.readLong()
+ vr.originalSize = source.readLong()
+ if (version >= 2) {
+ vr.charset = source.readString()
+ }
+ // skip over all fields added in future versions of this parcel
+ source.setDataPosition(startPosition + parcelableSize)
+ return vr
}
- override fun toString(): String {
- var out = "\nfilename: $filename"
- out += "\nmimeType: $mimeType"
- out += "\nmodificationTime: $modificationTime"
- out += "\noriginalSize: $originalSize"
- out += "\ncharset: $charset"
- return out
+ override fun newArray(size: Int): Array<OpenPgpMetadata?> {
+ return arrayOfNulls(size)
}
+ }
+
+ override fun toString(): String {
+ var out = "\nfilename: $filename"
+ out += "\nmimeType: $mimeType"
+ out += "\nmodificationTime: $modificationTime"
+ out += "\noriginalSize: $originalSize"
+ out += "\ncharset: $charset"
+ return out
+ }
}
diff --git a/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpSignatureResult.kt b/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpSignatureResult.kt
index 78a2d741..3b7e6d1b 100644
--- a/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpSignatureResult.kt
+++ b/openpgp-ktx/src/main/java/org/openintents/openpgp/OpenPgpSignatureResult.kt
@@ -14,281 +14,272 @@ import me.msfjarvis.openpgpktx.util.OpenPgpUtils
public class OpenPgpSignatureResult : Parcelable {
- private val result: Int
- private val keyId: Long
- private val primaryUserId: String?
- private val userIds: ArrayList<String>?
- private val confirmedUserIds: ArrayList<String>?
- private val senderStatusResult: SenderStatusResult?
- private val signatureTimestamp: Date?
- private val autocryptPeerentityResult: AutocryptPeerResult?
-
- private constructor(
- signatureStatus: Int,
- signatureUserId: String?,
- keyId: Long,
- userIds: ArrayList<String>?,
- confirmedUserIds: ArrayList<String>?,
- senderStatusResult: SenderStatusResult?,
- signatureOnly: Boolean?,
- signatureTimestamp: Date?,
- autocryptPeerentityResult: AutocryptPeerResult?
- ) {
- result = signatureStatus
- primaryUserId = signatureUserId
- this.keyId = keyId
- this.userIds = userIds
- this.confirmedUserIds = confirmedUserIds
- this.senderStatusResult = senderStatusResult
- this.signatureTimestamp = signatureTimestamp
- this.autocryptPeerentityResult = autocryptPeerentityResult
+ private val result: Int
+ private val keyId: Long
+ private val primaryUserId: String?
+ private val userIds: ArrayList<String>?
+ private val confirmedUserIds: ArrayList<String>?
+ private val senderStatusResult: SenderStatusResult?
+ private val signatureTimestamp: Date?
+ private val autocryptPeerentityResult: AutocryptPeerResult?
+
+ private constructor(
+ signatureStatus: Int,
+ signatureUserId: String?,
+ keyId: Long,
+ userIds: ArrayList<String>?,
+ confirmedUserIds: ArrayList<String>?,
+ senderStatusResult: SenderStatusResult?,
+ signatureOnly: Boolean?,
+ signatureTimestamp: Date?,
+ autocryptPeerentityResult: AutocryptPeerResult?
+ ) {
+ result = signatureStatus
+ primaryUserId = signatureUserId
+ this.keyId = keyId
+ this.userIds = userIds
+ this.confirmedUserIds = confirmedUserIds
+ this.senderStatusResult = senderStatusResult
+ this.signatureTimestamp = signatureTimestamp
+ this.autocryptPeerentityResult = autocryptPeerentityResult
+ }
+
+ private constructor(source: Parcel, version: Int) {
+ result = source.readInt()
+ // we dropped support for signatureOnly, but need to skip the value for compatibility
+ source.readByte()
+ primaryUserId = source.readString()
+ keyId = source.readLong()
+ userIds =
+ if (version > 1) {
+ source.createStringArrayList()
+ } else {
+ null
+ }
+ // backward compatibility for this exact version
+ if (version > 2) {
+ senderStatusResult = readEnumWithNullAndFallback(source, SenderStatusResult.values(), SenderStatusResult.UNKNOWN)
+ confirmedUserIds = source.createStringArrayList()
+ } else {
+ senderStatusResult = SenderStatusResult.UNKNOWN
+ confirmedUserIds = null
}
-
- private constructor(source: Parcel, version: Int) {
- result = source.readInt()
- // we dropped support for signatureOnly, but need to skip the value for compatibility
- source.readByte()
- primaryUserId = source.readString()
- keyId = source.readLong()
- userIds = if (version > 1) {
- source.createStringArrayList()
- } else {
- null
- }
- // backward compatibility for this exact version
- if (version > 2) {
- senderStatusResult = readEnumWithNullAndFallback(
- source,
- SenderStatusResult.values(),
- SenderStatusResult.UNKNOWN
- )
- confirmedUserIds = source.createStringArrayList()
- } else {
- senderStatusResult = SenderStatusResult.UNKNOWN
- confirmedUserIds = null
- }
- signatureTimestamp = if (version > 3) {
- if (source.readInt() > 0) Date(source.readLong()) else null
- } else {
- null
- }
- autocryptPeerentityResult = if (version > 4) {
- readEnumWithNullAndFallback(
- source,
- AutocryptPeerResult.values(),
- null
- )
- } else {
- null
- }
- }
-
- public fun getUserIds(): List<String> {
- return (userIds ?: arrayListOf()).toList()
+ signatureTimestamp =
+ if (version > 3) {
+ if (source.readInt() > 0) Date(source.readLong()) else null
+ } else {
+ null
+ }
+ autocryptPeerentityResult =
+ if (version > 4) {
+ readEnumWithNullAndFallback(source, AutocryptPeerResult.values(), null)
+ } else {
+ null
+ }
+ }
+
+ public fun getUserIds(): List<String> {
+ return (userIds ?: arrayListOf()).toList()
+ }
+
+ public fun getConfirmedUserIds(): List<String> {
+ return (confirmedUserIds ?: arrayListOf()).toList()
+ }
+
+ override fun describeContents(): Int {
+ return 0
+ }
+
+ override fun writeToParcel(dest: Parcel, flags: Int) {
+ /**
+ * NOTE: When adding fields in the process of updating this API, make sure to bump
+ * [.PARCELABLE_VERSION].
+ */
+ dest.writeInt(PARCELABLE_VERSION)
+ // Inject a placeholder that will store the parcel size from this point on
+ // (not including the size itself).
+ val sizePosition = dest.dataPosition()
+ dest.writeInt(0)
+ val startPosition = dest.dataPosition()
+ // version 1
+ dest.writeInt(result)
+ // signatureOnly is deprecated since version 3. we pass a dummy value for compatibility
+ dest.writeByte(0.toByte())
+ dest.writeString(primaryUserId)
+ dest.writeLong(keyId)
+ // version 2
+ dest.writeStringList(userIds)
+ // version 3
+ writeEnumWithNull(dest, senderStatusResult)
+ dest.writeStringList(confirmedUserIds)
+ // version 4
+ if (signatureTimestamp != null) {
+ dest.writeInt(1)
+ dest.writeLong(signatureTimestamp.time)
+ } else {
+ dest.writeInt(0)
}
-
- public fun getConfirmedUserIds(): List<String> {
- return (confirmedUserIds ?: arrayListOf()).toList()
- }
-
- override fun describeContents(): Int {
- return 0
+ // version 5
+ writeEnumWithNull(dest, autocryptPeerentityResult)
+ // Go back and write the size
+ val parcelableSize = dest.dataPosition() - startPosition
+ dest.setDataPosition(sizePosition)
+ dest.writeInt(parcelableSize)
+ dest.setDataPosition(startPosition + parcelableSize)
+ }
+
+ override fun toString(): String {
+ var out = "\nresult: $result"
+ out += "\nprimaryUserId: $primaryUserId"
+ out += "\nuserIds: $userIds"
+ out += "\nkeyId: " + OpenPgpUtils.convertKeyIdToHex(keyId)
+ return out
+ }
+
+ @Deprecated("")
+ public fun withSignatureOnlyFlag(signatureOnly: Boolean): OpenPgpSignatureResult {
+ return OpenPgpSignatureResult(
+ result,
+ primaryUserId,
+ keyId,
+ userIds,
+ confirmedUserIds,
+ senderStatusResult,
+ signatureOnly,
+ signatureTimestamp,
+ autocryptPeerentityResult
+ )
+ }
+
+ public fun withAutocryptPeerResult(autocryptPeerentityResult: AutocryptPeerResult?): OpenPgpSignatureResult {
+ return OpenPgpSignatureResult(
+ result,
+ primaryUserId,
+ keyId,
+ userIds,
+ confirmedUserIds,
+ senderStatusResult,
+ null,
+ signatureTimestamp,
+ autocryptPeerentityResult
+ )
+ }
+
+ public enum class SenderStatusResult {
+ UNKNOWN,
+ USER_ID_CONFIRMED,
+ USER_ID_UNCONFIRMED,
+ USER_ID_MISSING
+ }
+
+ public enum class AutocryptPeerResult {
+ OK,
+ NEW,
+ MISMATCH
+ }
+
+ public companion object CREATOR : Creator<OpenPgpSignatureResult> {
+
+ /**
+ * Since there might be a case where new versions of the client using the library getting old
+ * versions of the protocol (and thus old versions of this class), we need a versioning system
+ * for the parcels sent between the clients and the providers.
+ */
+ private const val PARCELABLE_VERSION = 5
+
+ // content not signed
+ public const val RESULT_NO_SIGNATURE: Int = -1
+
+ // invalid signature!
+ public const val RESULT_INVALID_SIGNATURE: Int = 0
+
+ // successfully verified signature, with confirmed key
+ public const val RESULT_VALID_KEY_CONFIRMED: Int = 1
+
+ // no key was found for this signature verification
+ public const val RESULT_KEY_MISSING: Int = 2
+
+ // successfully verified signature, but with unconfirmed key
+ public const val RESULT_VALID_KEY_UNCONFIRMED: Int = 3
+
+ // key has been revoked -> invalid signature!
+ public const val RESULT_INVALID_KEY_REVOKED: Int = 4
+
+ // key is expired -> invalid signature!
+ public const val RESULT_INVALID_KEY_EXPIRED: Int = 5
+
+ // insecure cryptographic algorithms/protocol -> invalid signature!
+ public const val RESULT_INVALID_KEY_INSECURE: Int = 6
+
+ override fun createFromParcel(source: Parcel): OpenPgpSignatureResult? {
+ val version = source.readInt() // parcelableVersion
+ val parcelableSize = source.readInt()
+ val startPosition = source.dataPosition()
+ val vr = OpenPgpSignatureResult(source, version)
+ // skip over all fields added in future versions of this parcel
+ source.setDataPosition(startPosition + parcelableSize)
+ return vr
}
- override fun writeToParcel(dest: Parcel, flags: Int) {
- /**
- * NOTE: When adding fields in the process of updating this API, make sure to bump
- * [.PARCELABLE_VERSION].
- */
- dest.writeInt(PARCELABLE_VERSION)
- // Inject a placeholder that will store the parcel size from this point on
- // (not including the size itself).
- val sizePosition = dest.dataPosition()
- dest.writeInt(0)
- val startPosition = dest.dataPosition()
- // version 1
- dest.writeInt(result)
- // signatureOnly is deprecated since version 3. we pass a dummy value for compatibility
- dest.writeByte(0.toByte())
- dest.writeString(primaryUserId)
- dest.writeLong(keyId)
- // version 2
- dest.writeStringList(userIds)
- // version 3
- writeEnumWithNull(dest, senderStatusResult)
- dest.writeStringList(confirmedUserIds)
- // version 4
- if (signatureTimestamp != null) {
- dest.writeInt(1)
- dest.writeLong(signatureTimestamp.time)
- } else {
- dest.writeInt(0)
- }
- // version 5
- writeEnumWithNull(dest, autocryptPeerentityResult)
- // Go back and write the size
- val parcelableSize = dest.dataPosition() - startPosition
- dest.setDataPosition(sizePosition)
- dest.writeInt(parcelableSize)
- dest.setDataPosition(startPosition + parcelableSize)
+ override fun newArray(size: Int): Array<OpenPgpSignatureResult?> {
+ return arrayOfNulls(size)
}
- override fun toString(): String {
- var out = "\nresult: $result"
- out += "\nprimaryUserId: $primaryUserId"
- out += "\nuserIds: $userIds"
- out += "\nkeyId: " + OpenPgpUtils.convertKeyIdToHex(keyId)
- return out
+ public fun createWithValidSignature(
+ signatureStatus: Int,
+ primaryUserId: String?,
+ keyId: Long,
+ userIds: ArrayList<String>?,
+ confirmedUserIds: ArrayList<String>?,
+ senderStatusResult: SenderStatusResult?,
+ signatureTimestamp: Date?
+ ): OpenPgpSignatureResult {
+ require(
+ !(signatureStatus == RESULT_NO_SIGNATURE ||
+ signatureStatus == RESULT_KEY_MISSING ||
+ signatureStatus == RESULT_INVALID_SIGNATURE)
+ ) { "can only use this method for valid types of signatures" }
+ return OpenPgpSignatureResult(
+ signatureStatus,
+ primaryUserId,
+ keyId,
+ userIds,
+ confirmedUserIds,
+ senderStatusResult,
+ null,
+ signatureTimestamp,
+ null
+ )
}
- @Deprecated("")
- public fun withSignatureOnlyFlag(signatureOnly: Boolean): OpenPgpSignatureResult {
- return OpenPgpSignatureResult(
- result, primaryUserId, keyId, userIds, confirmedUserIds,
- senderStatusResult, signatureOnly, signatureTimestamp, autocryptPeerentityResult
- )
+ public fun createWithNoSignature(): OpenPgpSignatureResult {
+ return OpenPgpSignatureResult(RESULT_NO_SIGNATURE, null, 0L, null, null, null, null, null, null)
}
- public fun withAutocryptPeerResult(autocryptPeerentityResult: AutocryptPeerResult?): OpenPgpSignatureResult {
- return OpenPgpSignatureResult(
- result, primaryUserId, keyId, userIds, confirmedUserIds,
- senderStatusResult, null, signatureTimestamp, autocryptPeerentityResult
- )
+ public fun createWithKeyMissing(keyId: Long, signatureTimestamp: Date?): OpenPgpSignatureResult {
+ return OpenPgpSignatureResult(RESULT_KEY_MISSING, null, keyId, null, null, null, null, signatureTimestamp, null)
}
- public enum class SenderStatusResult {
- UNKNOWN, USER_ID_CONFIRMED, USER_ID_UNCONFIRMED, USER_ID_MISSING;
+ public fun createWithInvalidSignature(): OpenPgpSignatureResult {
+ return OpenPgpSignatureResult(RESULT_INVALID_SIGNATURE, null, 0L, null, null, null, null, null, null)
}
- public enum class AutocryptPeerResult {
- OK, NEW, MISMATCH;
+ private fun <T : Enum<T>?> readEnumWithNullAndFallback(source: Parcel, enumValues: Array<T>, fallback: T?): T? {
+ val valueOrdinal = source.readInt()
+ if (valueOrdinal == -1) {
+ return null
+ }
+ return if (valueOrdinal >= enumValues.size) {
+ fallback
+ } else enumValues[valueOrdinal]
}
- public companion object CREATOR : Creator<OpenPgpSignatureResult> {
-
- /**
- * Since there might be a case where new versions of the client using the library getting
- * old versions of the protocol (and thus old versions of this class), we need a versioning
- * system for the parcels sent between the clients and the providers.
- */
- private const val PARCELABLE_VERSION = 5
-
- // content not signed
- public const val RESULT_NO_SIGNATURE: Int = -1
-
- // invalid signature!
- public const val RESULT_INVALID_SIGNATURE: Int = 0
-
- // successfully verified signature, with confirmed key
- public const val RESULT_VALID_KEY_CONFIRMED: Int = 1
-
- // no key was found for this signature verification
- public const val RESULT_KEY_MISSING: Int = 2
-
- // successfully verified signature, but with unconfirmed key
- public const val RESULT_VALID_KEY_UNCONFIRMED: Int = 3
-
- // key has been revoked -> invalid signature!
- public const val RESULT_INVALID_KEY_REVOKED: Int = 4
-
- // key is expired -> invalid signature!
- public const val RESULT_INVALID_KEY_EXPIRED: Int = 5
-
- // insecure cryptographic algorithms/protocol -> invalid signature!
- public const val RESULT_INVALID_KEY_INSECURE: Int = 6
-
- override fun createFromParcel(source: Parcel): OpenPgpSignatureResult? {
- val version = source.readInt() // parcelableVersion
- val parcelableSize = source.readInt()
- val startPosition = source.dataPosition()
- val vr = OpenPgpSignatureResult(source, version)
- // skip over all fields added in future versions of this parcel
- source.setDataPosition(startPosition + parcelableSize)
- return vr
- }
-
- override fun newArray(size: Int): Array<OpenPgpSignatureResult?> {
- return arrayOfNulls(size)
- }
-
- public fun createWithValidSignature(
- signatureStatus: Int,
- primaryUserId: String?,
- keyId: Long,
- userIds: ArrayList<String>?,
- confirmedUserIds: ArrayList<String>?,
- senderStatusResult: SenderStatusResult?,
- signatureTimestamp: Date?
- ): OpenPgpSignatureResult {
- require(!(signatureStatus == RESULT_NO_SIGNATURE || signatureStatus == RESULT_KEY_MISSING || signatureStatus == RESULT_INVALID_SIGNATURE)) { "can only use this method for valid types of signatures" }
- return OpenPgpSignatureResult(
- signatureStatus, primaryUserId, keyId, userIds, confirmedUserIds,
- senderStatusResult, null, signatureTimestamp, null
- )
- }
-
- public fun createWithNoSignature(): OpenPgpSignatureResult {
- return OpenPgpSignatureResult(
- RESULT_NO_SIGNATURE,
- null,
- 0L,
- null,
- null,
- null,
- null,
- null,
- null
- )
- }
-
- public fun createWithKeyMissing(keyId: Long, signatureTimestamp: Date?): OpenPgpSignatureResult {
- return OpenPgpSignatureResult(
- RESULT_KEY_MISSING,
- null,
- keyId,
- null,
- null,
- null,
- null,
- signatureTimestamp,
- null
- )
- }
-
- public fun createWithInvalidSignature(): OpenPgpSignatureResult {
- return OpenPgpSignatureResult(
- RESULT_INVALID_SIGNATURE,
- null,
- 0L,
- null,
- null,
- null,
- null,
- null,
- null
- )
- }
-
- private fun <T : Enum<T>?> readEnumWithNullAndFallback(
- source: Parcel,
- enumValues: Array<T>,
- fallback: T?
- ): T? {
- val valueOrdinal = source.readInt()
- if (valueOrdinal == -1) {
- return null
- }
- return if (valueOrdinal >= enumValues.size) {
- fallback
- } else enumValues[valueOrdinal]
- }
-
- private fun writeEnumWithNull(dest: Parcel, enumValue: Enum<*>?) {
- if (enumValue == null) {
- dest.writeInt(-1)
- return
- }
- dest.writeInt(enumValue.ordinal)
- }
+ private fun writeEnumWithNull(dest: Parcel, enumValue: Enum<*>?) {
+ if (enumValue == null) {
+ dest.writeInt(-1)
+ return
+ }
+ dest.writeInt(enumValue.ordinal)
}
+ }
}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 9211790c..87a9e75f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -3,12 +3,14 @@
* SPDX-License-Identifier: GPL-3.0-only
*/
include(":autofill-parser")
+
include(":app")
+
include(":openpgp-ktx")
pluginManagement {
- repositories {
- gradlePluginPortal()
- mavenCentral()
- }
+ repositories {
+ gradlePluginPortal()
+ mavenCentral()
+ }
}