diff options
author | Harsh Shandilya <me@msfjarvis.dev> | 2021-03-09 14:53:11 +0530 |
---|---|---|
committer | Harsh Shandilya <me@msfjarvis.dev> | 2021-03-18 11:40:30 +0530 |
commit | 774fda83ac276a635e3402034b1eedbd10be916f (patch) | |
tree | 57783a953fcd193d32f1e3dc8df26e07f03df328 | |
parent | be31ae37f443982b377d027cd613f04d0926568d (diff) |
all: reformat with ktfmt
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
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=" " /> <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() + } } |