all: reformat with ktfmt

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
Harsh Shandilya 2021-03-09 14:53:11 +05:30
parent be31ae37f4
commit 774fda83ac
No known key found for this signature in database
GPG key ID: 366D7BBAD1031E80
145 changed files with 12016 additions and 12490 deletions

View file

@ -4,7 +4,7 @@
<option name="LINE_SEPARATOR" value="&#10;" /> <option name="LINE_SEPARATOR" value="&#10;" />
<option name="WRAP_WHEN_TYPING_REACHES_RIGHT_MARGIN" value="true" /> <option name="WRAP_WHEN_TYPING_REACHES_RIGHT_MARGIN" value="true" />
<option name="FORMATTER_TAGS_ENABLED" 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"> <option name="DO_NOT_FORMAT">
<list> <list>
<fileSet type="namedScope" name="third_party" pattern="src[Android-Password-Store.app]:mozilla.components.lib.publicsuffixlist..*" /> <fileSet type="namedScope" name="third_party" pattern="src[Android-Password-Store.app]:mozilla.components.lib.publicsuffixlist..*" />
@ -161,7 +161,7 @@
</codeStyleSettings> </codeStyleSettings>
<codeStyleSettings language="kotlin"> <codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" /> <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_DECLARATIONS" value="0" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" /> <option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" /> <option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
@ -183,4 +183,4 @@
</indentOptions> </indentOptions>
</codeStyleSettings> </codeStyleSettings>
</code_scheme> </code_scheme>
</component> </component>

View file

@ -5,104 +5,100 @@
import com.android.build.gradle.internal.api.BaseVariantOutputImpl import com.android.build.gradle.internal.api.BaseVariantOutputImpl
plugins { plugins {
id("com.android.application") id("com.android.application")
kotlin("android") kotlin("android")
`versioning-plugin` `versioning-plugin`
`aps-plugin` `aps-plugin`
`crowdin-plugin` `crowdin-plugin`
} }
configure<CrowdinExtension> { configure<CrowdinExtension> { projectName = "android-password-store" }
projectName = "android-password-store"
}
android { android {
if (isSnapshot()) { if (isSnapshot()) {
applicationVariants.all { applicationVariants.all {
outputs.all { outputs.all {
(this as BaseVariantOutputImpl).outputFileName = "aps-${flavorName}_$versionName.apk" (this as BaseVariantOutputImpl).outputFileName = "aps-${flavorName}_$versionName.apk"
} }
}
} }
}
defaultConfig { defaultConfig {
applicationId = "dev.msfjarvis.aps" applicationId = "dev.msfjarvis.aps"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
lintOptions { lintOptions {
isAbortOnError = true isAbortOnError = true
isCheckReleaseBuilds = false isCheckReleaseBuilds = false
disable("MissingTranslation", "PluralsCandidate", "ImpliedQuantity") disable("MissingTranslation", "PluralsCandidate", "ImpliedQuantity")
} }
flavorDimensions("free") flavorDimensions("free")
productFlavors { productFlavors {
create("free") { create("free") {}
} create("nonFree") {}
create("nonFree") { }
}
}
} }
dependencies { dependencies {
compileOnly(Dependencies.AndroidX.annotation) compileOnly(Dependencies.AndroidX.annotation)
implementation(project(":autofill-parser")) implementation(project(":autofill-parser"))
implementation(project(":openpgp-ktx")) implementation(project(":openpgp-ktx"))
implementation(Dependencies.AndroidX.activity_ktx) implementation(Dependencies.AndroidX.activity_ktx)
implementation(Dependencies.AndroidX.appcompat) implementation(Dependencies.AndroidX.appcompat)
implementation(Dependencies.AndroidX.autofill) implementation(Dependencies.AndroidX.autofill)
implementation(Dependencies.AndroidX.biometric_ktx) implementation(Dependencies.AndroidX.biometric_ktx)
implementation(Dependencies.AndroidX.constraint_layout) implementation(Dependencies.AndroidX.constraint_layout)
implementation(Dependencies.AndroidX.core_ktx) implementation(Dependencies.AndroidX.core_ktx)
implementation(Dependencies.AndroidX.documentfile) implementation(Dependencies.AndroidX.documentfile)
implementation(Dependencies.AndroidX.fragment_ktx) implementation(Dependencies.AndroidX.fragment_ktx)
implementation(Dependencies.AndroidX.lifecycle_common) implementation(Dependencies.AndroidX.lifecycle_common)
implementation(Dependencies.AndroidX.lifecycle_livedata_ktx) implementation(Dependencies.AndroidX.lifecycle_livedata_ktx)
implementation(Dependencies.AndroidX.lifecycle_viewmodel_ktx) implementation(Dependencies.AndroidX.lifecycle_viewmodel_ktx)
implementation(Dependencies.AndroidX.material) implementation(Dependencies.AndroidX.material)
implementation(Dependencies.AndroidX.preference) implementation(Dependencies.AndroidX.preference)
implementation(Dependencies.AndroidX.recycler_view) implementation(Dependencies.AndroidX.recycler_view)
implementation(Dependencies.AndroidX.recycler_view_selection) implementation(Dependencies.AndroidX.recycler_view_selection)
implementation(Dependencies.AndroidX.security) implementation(Dependencies.AndroidX.security)
implementation(Dependencies.AndroidX.swiperefreshlayout) implementation(Dependencies.AndroidX.swiperefreshlayout)
implementation(Dependencies.Kotlin.Coroutines.android) implementation(Dependencies.Kotlin.Coroutines.android)
implementation(Dependencies.Kotlin.Coroutines.core) implementation(Dependencies.Kotlin.Coroutines.core)
implementation(Dependencies.FirstParty.zxing_android_embedded) implementation(Dependencies.FirstParty.zxing_android_embedded)
implementation(Dependencies.ThirdParty.bouncycastle) implementation(Dependencies.ThirdParty.bouncycastle)
implementation(Dependencies.ThirdParty.commons_codec) implementation(Dependencies.ThirdParty.commons_codec)
implementation(Dependencies.ThirdParty.eddsa) implementation(Dependencies.ThirdParty.eddsa)
implementation(Dependencies.ThirdParty.fastscroll) implementation(Dependencies.ThirdParty.fastscroll)
implementation(Dependencies.ThirdParty.jgit) { implementation(Dependencies.ThirdParty.jgit) {
exclude(group = "org.apache.httpcomponents", module = "httpclient") exclude(group = "org.apache.httpcomponents", module = "httpclient")
} }
implementation(Dependencies.ThirdParty.kotlin_result) implementation(Dependencies.ThirdParty.kotlin_result)
implementation(Dependencies.ThirdParty.modern_android_prefs) implementation(Dependencies.ThirdParty.modern_android_prefs)
implementation(Dependencies.ThirdParty.plumber) implementation(Dependencies.ThirdParty.plumber)
implementation(Dependencies.ThirdParty.ssh_auth) implementation(Dependencies.ThirdParty.ssh_auth)
implementation(Dependencies.ThirdParty.sshj) implementation(Dependencies.ThirdParty.sshj)
implementation(Dependencies.ThirdParty.timber) implementation(Dependencies.ThirdParty.timber)
implementation(Dependencies.ThirdParty.timberkt) implementation(Dependencies.ThirdParty.timberkt)
if (isSnapshot()) { if (isSnapshot()) {
implementation(Dependencies.ThirdParty.leakcanary) implementation(Dependencies.ThirdParty.leakcanary)
implementation(Dependencies.ThirdParty.whatthestack) implementation(Dependencies.ThirdParty.whatthestack)
} else { } else {
debugImplementation(Dependencies.ThirdParty.leakcanary) debugImplementation(Dependencies.ThirdParty.leakcanary)
debugImplementation(Dependencies.ThirdParty.whatthestack) debugImplementation(Dependencies.ThirdParty.whatthestack)
} }
"nonFreeImplementation"(Dependencies.NonFree.google_play_auth_api_phone) "nonFreeImplementation"(Dependencies.NonFree.google_play_auth_api_phone)
// Testing-only dependencies // Testing-only dependencies
androidTestImplementation(Dependencies.Testing.junit) androidTestImplementation(Dependencies.Testing.junit)
androidTestImplementation(Dependencies.Testing.kotlin_test_junit) androidTestImplementation(Dependencies.Testing.kotlin_test_junit)
androidTestImplementation(Dependencies.Testing.AndroidX.runner) androidTestImplementation(Dependencies.Testing.AndroidX.runner)
androidTestImplementation(Dependencies.Testing.AndroidX.rules) androidTestImplementation(Dependencies.Testing.AndroidX.rules)
testImplementation(Dependencies.Testing.junit) testImplementation(Dependencies.Testing.junit)
testImplementation(Dependencies.Testing.kotlin_test_junit) testImplementation(Dependencies.Testing.kotlin_test_junit)
} }

View file

@ -18,98 +18,105 @@ import org.junit.Test
class PasswordEntryAndroidTest { class PasswordEntryAndroidTest {
private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder()) private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder())
@Test fun testGetPassword() { @Test
assertEquals("fooooo", makeEntry("fooooo\nbla\n").password) fun testGetPassword() {
assertEquals("fooooo", makeEntry("fooooo\nbla").password) assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
assertEquals("fooooo", makeEntry("fooooo\n").password) assertEquals("fooooo", makeEntry("fooooo\nbla").password)
assertEquals("fooooo", makeEntry("fooooo").password) assertEquals("fooooo", makeEntry("fooooo\n").password)
assertEquals("", makeEntry("\nblubb\n").password) assertEquals("fooooo", makeEntry("fooooo").password)
assertEquals("", makeEntry("\nblubb").password) assertEquals("", makeEntry("\nblubb\n").password)
assertEquals("", makeEntry("\n").password) assertEquals("", makeEntry("\nblubb").password)
assertEquals("", makeEntry("").password) assertEquals("", makeEntry("\n").password)
assertEquals("", makeEntry("").password)
}
@Test
fun testGetExtraContent() {
assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent)
assertEquals("bla", makeEntry("fooooo\nbla").extraContent)
assertEquals("", makeEntry("fooooo\n").extraContent)
assertEquals("", makeEntry("fooooo").extraContent)
assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent)
assertEquals("blubb", makeEntry("\nblubb").extraContent)
assertEquals("", makeEntry("\n").extraContent)
assertEquals("", makeEntry("").extraContent)
}
@Test
fun testGetUsername() {
for (field in PasswordEntry.USERNAME_FIELDS) {
assertEquals("username", makeEntry("\n$field username").username)
assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username)
} }
assertEquals("username", makeEntry("secret\nextra\nlogin: username\ncontent\n").username)
assertEquals("username", makeEntry("\nextra\nusername: username\ncontent\n").username)
assertEquals("username", makeEntry("\nUSERNaMe: username\ncontent\n").username)
assertEquals("username", makeEntry("\nlogin: username").username)
assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username)
assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username)
assertEquals("username", makeEntry("\nLOGiN:username").username)
assertNull(makeEntry("secret\nextra\ncontent\n").username)
}
@Test fun testGetExtraContent() { @Test
assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent) fun testHasUsername() {
assertEquals("bla", makeEntry("fooooo\nbla").extraContent) assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername())
assertEquals("", makeEntry("fooooo\n").extraContent) assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername())
assertEquals("", makeEntry("fooooo").extraContent) assertFalse(makeEntry("secret\nlogin failed\n").hasUsername())
assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent) assertFalse(makeEntry("\n").hasUsername())
assertEquals("blubb", makeEntry("\nblubb").extraContent) assertFalse(makeEntry("").hasUsername())
assertEquals("", makeEntry("\n").extraContent) }
assertEquals("", makeEntry("").extraContent)
}
@Test fun testGetUsername() { @Test
for (field in PasswordEntry.USERNAME_FIELDS) { fun testGeneratesOtpFromTotpUri() {
assertEquals("username", makeEntry("\n$field username").username) val entry = makeEntry("secret\nextra\n$TOTP_URI")
assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username) assertTrue(entry.hasTotp())
} val code =
assertEquals( Otp.calculateCode(
"username", entry.totpSecret!!,
makeEntry("secret\nextra\nlogin: username\ncontent\n").username) // The hardcoded date value allows this test to stay reproducible.
assertEquals( Date(8640000).time / (1000 * entry.totpPeriod),
"username", entry.totpAlgorithm,
makeEntry("\nextra\nusername: username\ncontent\n").username) entry.digits
assertEquals( )
"username", makeEntry("\nUSERNaMe: username\ncontent\n").username) .get()
assertEquals("username", makeEntry("\nlogin: username").username) assertNotNull(code) { "Generated OTP cannot be null" }
assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username) assertEquals(entry.digits.toInt(), code.length)
assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username) assertEquals("545293", code)
assertEquals("username", makeEntry("\nLOGiN:username").username) }
assertNull(makeEntry("secret\nextra\ncontent\n").username)
}
@Test fun testHasUsername() { @Test
assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername()) fun testGeneratesOtpWithOnlyUriInFile() {
assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername()) val entry = makeEntry(TOTP_URI)
assertFalse(makeEntry("secret\nlogin failed\n").hasUsername()) assertTrue(entry.password.isEmpty())
assertFalse(makeEntry("\n").hasUsername()) assertTrue(entry.hasTotp())
assertFalse(makeEntry("").hasUsername()) 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() { @Test
val entry = makeEntry("secret\nextra\n$TOTP_URI") fun testOnlyLooksForUriInFirstLine() {
assertTrue(entry.hasTotp()) val entry = makeEntry("id:\n$TOTP_URI")
val code = Otp.calculateCode( assertTrue(entry.password.isNotEmpty())
entry.totpSecret!!, assertTrue(entry.hasTotp())
// The hardcoded date value allows this test to stay reproducible. assertFalse(entry.hasUsername())
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() { companion object {
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() { const val TOTP_URI =
val entry = makeEntry("id:\n$TOTP_URI") "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
assertTrue(entry.password.isNotEmpty()) }
assertTrue(entry.hasTotp())
assertFalse(entry.hasUsername())
}
companion object {
const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
}
} }

View file

@ -19,102 +19,103 @@ import org.junit.Test
class MigrationsTest { class MigrationsTest {
private fun checkOldKeysAreRemoved(context: Context) = with(context.sharedPrefs) { private fun checkOldKeysAreRemoved(context: Context) =
assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT)) with(context.sharedPrefs) {
assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME)) assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT))
assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER)) assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME))
assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION)) assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER))
assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL)) assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION))
assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
} }
@Test @Test
fun verifySshWithCustomPortMigration() { fun verifySshWithCustomPortMigration() {
val context = Application.instance.applicationContext val context = Application.instance.applicationContext
context.sharedPrefs.edit { context.sharedPrefs.edit {
clear() clear()
putString(PreferenceKeys.GIT_REMOTE_PORT, "2200") putString(PreferenceKeys.GIT_REMOTE_PORT, "2200")
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo") putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102") putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref) putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.Password.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"
)
} }
runMigrations(context)
checkOldKeysAreRemoved(context)
assertEquals(
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
"ssh://msfjarvis@192.168.0.102:2200/mnt/disk3/pass-repo"
)
}
@Test @Test
fun verifySshWithDefaultPortMigration() { fun verifySshWithDefaultPortMigration() {
val context = Application.instance.applicationContext val context = Application.instance.applicationContext
context.sharedPrefs.edit { context.sharedPrefs.edit {
clear() clear()
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo") putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102") putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref) putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.SshKey.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"
)
} }
runMigrations(context)
checkOldKeysAreRemoved(context)
assertEquals(
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
"msfjarvis@192.168.0.102:/mnt/disk3/pass-repo"
)
}
@Test @Test
fun verifyHttpsWithGitHubMigration() { fun verifyHttpsWithGitHubMigration() {
val context = Application.instance.applicationContext val context = Application.instance.applicationContext
context.sharedPrefs.edit { context.sharedPrefs.edit {
clear() clear()
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis") putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "Android-Password-Store/pass-test") putString(PreferenceKeys.GIT_REMOTE_LOCATION, "Android-Password-Store/pass-test")
putString(PreferenceKeys.GIT_REMOTE_SERVER, "github.com") putString(PreferenceKeys.GIT_REMOTE_SERVER, "github.com")
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Https.pref) putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Https.pref)
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.None.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"
)
} }
runMigrations(context)
checkOldKeysAreRemoved(context)
assertEquals(
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
"https://github.com/Android-Password-Store/pass-test"
)
}
@Test @Test
fun verifyHiddenFoldersMigrationIfDisabled() { fun verifyHiddenFoldersMigrationIfDisabled() {
val context = Application.instance.applicationContext val context = Application.instance.applicationContext
context.sharedPrefs.edit { clear() } context.sharedPrefs.edit { clear() }
runMigrations(context) runMigrations(context)
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true)) assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true))
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)) assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
} }
@Test @Test
fun verifyHiddenFoldersMigrationIfEnabled() { fun verifyHiddenFoldersMigrationIfEnabled() {
val context = Application.instance.applicationContext val context = Application.instance.applicationContext
context.sharedPrefs.edit { context.sharedPrefs.edit {
clear() clear()
putBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true) 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))
} }
runMigrations(context)
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false))
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
}
@Test @Test
fun verifyClearClipboardHistoryMigration() { fun verifyClearClipboardHistoryMigration() {
val context = Application.instance.applicationContext val context = Application.instance.applicationContext
context.sharedPrefs.edit { context.sharedPrefs.edit {
clear() clear()
putBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, true) 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))
} }
runMigrations(context)
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false))
assertFalse(context.sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X))
}
} }

View file

@ -10,36 +10,40 @@ import org.junit.Test
class UriTotpFinderTest { class UriTotpFinderTest {
private val totpFinder = UriTotpFinder() private val totpFinder = UriTotpFinder()
@Test @Test
fun findSecret() { fun findSecret() {
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI)) assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI))
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")) assertEquals(
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT)) "HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ",
} totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")
)
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT))
}
@Test @Test
fun findDigits() { fun findDigits() {
assertEquals("12", totpFinder.findDigits(TOTP_URI)) assertEquals("12", totpFinder.findDigits(TOTP_URI))
assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT)) assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT))
} }
@Test @Test
fun findPeriod() { fun findPeriod() {
assertEquals(25, totpFinder.findPeriod(TOTP_URI)) assertEquals(25, totpFinder.findPeriod(TOTP_URI))
assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT)) assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT))
} }
@Test @Test
fun findAlgorithm() { fun findAlgorithm() {
assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI)) assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI))
assertEquals("SHA256", totpFinder.findAlgorithm(PASS_FILE_CONTENT)) assertEquals("SHA256", totpFinder.findAlgorithm(PASS_FILE_CONTENT))
} }
companion object { 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 TOTP_URI =
const val PASS_FILE_CONTENT = "password\n$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"
}
} }

View file

@ -10,40 +10,46 @@ import kotlin.test.assertTrue
import org.junit.Test import org.junit.Test
private infix fun String.matchedForDomain(domain: String) = private infix fun String.matchedForDomain(domain: String) =
SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true
class StrictDomainRegexTest { class StrictDomainRegexTest {
@Test fun acceptsLiteralDomain() { @Test
assertTrue("work/example.org/john.doe@example.org.gpg" matchedForDomain "example.org") fun acceptsLiteralDomain() {
assertTrue("example.org/john.doe@example.org.gpg" matchedForDomain "example.org") assertTrue("work/example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
assertTrue("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() { @Test
assertTrue("work/www.example.org/john.doe@example.org.gpg" matchedForDomain "example.org") fun acceptsSubdomains() {
assertTrue("www2.example.org/john.doe@example.org.gpg" matchedForDomain "example.org") assertTrue("work/www.example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
assertTrue("www.login.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() { @Test
assertFalse("example.org.gpg" matchedForDomain "xample.org") fun rejectsPhishingAttempts() {
assertFalse("login.example.org.gpg" matchedForDomain "xample.org") assertFalse("example.org.gpg" matchedForDomain "xample.org")
assertFalse("example.org/john.doe@exmple.org.gpg" matchedForDomain "xample.org") assertFalse("login.example.org.gpg" matchedForDomain "xample.org")
assertFalse("example.org.gpg" matchedForDomain "e/xample.org") assertFalse("example.org/john.doe@exmple.org.gpg" matchedForDomain "xample.org")
} assertFalse("example.org.gpg" matchedForDomain "e/xample.org")
}
@Test fun rejectNonGpgComponentMatches() { @Test
assertFalse("work/example.org" matchedForDomain "example.org") fun rejectNonGpgComponentMatches() {
} assertFalse("work/example.org" matchedForDomain "example.org")
}
@Test fun rejectsEmailAddresses() { @Test
assertFalse("work/notexample.org/john.doe@example.org.gpg" matchedForDomain "example.org") fun rejectsEmailAddresses() {
assertFalse("work/notexample.org/john.doe@www.example.org.gpg" matchedForDomain "example.org") assertFalse("work/notexample.org/john.doe@example.org.gpg" matchedForDomain "example.org")
assertFalse("work/john.doe@www.example.org/foo.org" 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() { @Test
assertNull(SearchableRepositoryViewModel.generateStrictDomainRegex("ex/ample.org")) fun rejectsPathSeparators() {
} assertNull(SearchableRepositoryViewModel.generateStrictDomainRegex("ex/ample.org"))
}
} }

View file

@ -14,14 +14,14 @@ import androidx.appcompat.app.AppCompatActivity
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
class AutofillSmsActivity : AppCompatActivity() { class AutofillSmsActivity : AppCompatActivity() {
companion object { companion object {
fun shouldOfferFillFromSms(context: Context): Boolean { fun shouldOfferFillFromSms(context: Context): Boolean {
return false 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")
}
}
} }

View file

@ -22,45 +22,45 @@ import dev.msfjarvis.aps.util.settings.runMigrations
@Suppress("Unused") @Suppress("Unused")
class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener { class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener {
private val prefs by lazy { sharedPrefs } private val prefs by lazy { sharedPrefs }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
if (BuildConfig.ENABLE_DEBUG_FEATURES || if (BuildConfig.ENABLE_DEBUG_FEATURES || prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) {
prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) { plant(DebugTree())
plant(DebugTree())
}
prefs.registerOnSharedPreferenceChangeListener(this)
setNightMode()
setUpBouncyCastleForSshj()
runMigrations(applicationContext)
ProxyUtils.setDefaultProxy()
} }
prefs.registerOnSharedPreferenceChangeListener(this)
setNightMode()
setUpBouncyCastleForSshj()
runMigrations(applicationContext)
ProxyUtils.setDefaultProxy()
}
override fun onTerminate() { override fun onTerminate() {
prefs.unregisterOnSharedPreferenceChangeListener(this) prefs.unregisterOnSharedPreferenceChangeListener(this)
super.onTerminate() super.onTerminate()
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) {
if (key == PreferenceKeys.APP_THEME) {
setNightMode()
} }
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) { private fun setNightMode() {
if (key == PreferenceKeys.APP_THEME) { AppCompatDelegate.setDefaultNightMode(
setNightMode() 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() { companion object {
AppCompatDelegate.setDefaultNightMode(when (prefs.getString(PreferenceKeys.APP_THEME)
?: getString(R.string.app_theme_def)) {
"light" -> MODE_NIGHT_NO
"dark" -> MODE_NIGHT_YES
"follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM
else -> MODE_NIGHT_AUTO_BATTERY
})
}
companion object { lateinit var instance: Application
}
lateinit var instance: Application
}
} }

View file

@ -6,27 +6,30 @@
package dev.msfjarvis.aps.data.password package dev.msfjarvis.aps.data.password
class FieldItem(val key: String, val value: String, val action: ActionType) { class FieldItem(val key: String, val value: String, val action: ActionType) {
enum class ActionType { enum class ActionType {
COPY, HIDE COPY,
HIDE
}
enum class ItemType(val type: String) {
USERNAME("Username"),
PASSWORD("Password"),
OTP("OTP")
}
companion object {
// Extra helper methods
fun createOtpField(otp: String): FieldItem {
return FieldItem(ItemType.OTP.type, otp, ActionType.COPY)
} }
enum class ItemType(val type: String) { fun createPasswordField(password: String): FieldItem {
USERNAME("Username"), PASSWORD("Password"), OTP("OTP") return FieldItem(ItemType.PASSWORD.type, password, ActionType.HIDE)
} }
companion object { fun createUsernameField(username: String): FieldItem {
return FieldItem(ItemType.USERNAME.type, username, 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 createUsernameField(username: String): FieldItem {
return FieldItem(ItemType.USERNAME.type, username, ActionType.COPY)
}
} }
}
} }

View file

@ -18,178 +18,178 @@ import java.util.Date
*/ */
class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) { class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) {
val password: String val password: String
val username: 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"
}
}
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
}
private fun findUsername(): String? {
extraContent.splitToSequence("\n").forEach { line ->
for (prefix in USERNAME_FIELDS) {
if (line.startsWith(prefix, ignoreCase = true)) return line.substring(prefix.length).trimStart()
}
}
return null
}
private fun findAndStripPassword(passContent: List<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 findTotpSecret(decryptedContent: String): String? {
return totpFinder.findSecret(decryptedContent)
}
private fun findOtpDigits(decryptedContent: String): String {
return totpFinder.findDigits(decryptedContent)
}
private fun findTotpPeriod(decryptedContent: String): Long {
return totpFinder.findPeriod(decryptedContent)
}
private fun findTotpAlgorithm(decryptedContent: String): String {
return totpFinder.findAlgorithm(decryptedContent)
}
companion object {
private const val EXTRA_CONTENT = "Extra Content"
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val digits: String val USERNAME_FIELDS =
arrayOf(
"login:",
"username:",
"user:",
"account:",
"email:",
"name:",
"handle:",
"id:",
"identity:",
)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val totpSecret: String? val PASSWORD_FIELDS =
val totpPeriod: Long arrayOf(
"password:",
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) "secret:",
val totpAlgorithm: String "pass:",
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"
}
}
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
}
private fun findUsername(): String? {
extraContent.splitToSequence("\n").forEach { line ->
for (prefix in USERNAME_FIELDS) {
if (line.startsWith(prefix, ignoreCase = true))
return line.substring(prefix.length).trimStart()
}
}
return null
}
private fun findAndStripPassword(passContent: List<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 findTotpSecret(decryptedContent: String): String? {
return totpFinder.findSecret(decryptedContent)
}
private fun findOtpDigits(decryptedContent: String): String {
return totpFinder.findDigits(decryptedContent)
}
private fun findTotpPeriod(decryptedContent: String): Long {
return totpFinder.findPeriod(decryptedContent)
}
private fun findTotpAlgorithm(decryptedContent: String): String {
return totpFinder.findAlgorithm(decryptedContent)
}
companion object {
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:",
)
}
} }

View file

@ -8,79 +8,56 @@ import dev.msfjarvis.aps.ui.crypto.BasePgpActivity
import java.io.File import java.io.File
data class PasswordItem( data class PasswordItem(
val name: String, val name: String,
val parent: PasswordItem? = null, val parent: PasswordItem? = null,
val type: Char, val type: Char,
val file: File, val file: File,
val rootDir: File val rootDir: File
) : Comparable<PasswordItem> { ) : Comparable<PasswordItem> {
val fullPathToParent = file.absolutePath val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "")
.replace(rootDir.absolutePath, "")
.replace(file.name, "")
val longName = BasePgpActivity.getLongName( val longName = BasePgpActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString())
fullPathToParent,
rootDir.absolutePath,
toString())
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
return (other is PasswordItem) && (other.file == file) return (other is PasswordItem) && (other.file == file)
}
override fun compareTo(other: PasswordItem): Int {
return (type + name).compareTo(other.type + other.name, ignoreCase = true)
}
override fun toString(): String {
return name.replace("\\.gpg$".toRegex(), "")
}
override fun hashCode(): Int {
return 0
}
companion object {
const val TYPE_CATEGORY = 'c'
const val TYPE_PASSWORD = 'p'
@JvmStatic
fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
} }
override fun compareTo(other: PasswordItem): Int { @JvmStatic
return (type + name).compareTo(other.type + other.name, ignoreCase = true) fun newCategory(name: String, file: File, rootDir: File): PasswordItem {
return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
} }
override fun toString(): String { @JvmStatic
return name.replace("\\.gpg$".toRegex(), "") fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
} }
override fun hashCode(): Int { @JvmStatic
return 0 fun newPassword(name: String, file: File, rootDir: File): PasswordItem {
} return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
companion object {
const val TYPE_CATEGORY = 'c'
const val TYPE_PASSWORD = 'p'
@JvmStatic
fun newCategory(
name: String,
file: File,
parent: PasswordItem,
rootDir: File
): PasswordItem {
return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
}
@JvmStatic
fun newCategory(
name: String,
file: File,
rootDir: File
): PasswordItem {
return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
}
@JvmStatic
fun newPassword(
name: String,
file: File,
parent: PasswordItem,
rootDir: File
): PasswordItem {
return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
}
@JvmStatic
fun newPassword(
name: String,
file: File,
rootDir: File
): PasswordItem {
return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
}
} }
}
} }

View file

@ -31,213 +31,211 @@ import org.eclipse.jgit.util.FS_POSIX_Java6
object PasswordRepository { object PasswordRepository {
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private class FS_POSIX_Java6_with_optional_symlinks : FS_POSIX_Java6() { 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) { override fun createSymLink(source: File, target: String) {
val sourcePath = source.toPath() val sourcePath = source.toPath()
if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) Files.delete(sourcePath)
Files.delete(sourcePath) Files.createSymbolicLink(sourcePath, File(target).toPath())
Files.createSymbolicLink(sourcePath, File(target).toPath())
}
} }
}
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
private class Java7FSFactory : FS.FSFactory() { private class Java7FSFactory : FS.FSFactory() {
override fun detect(cygwinUsed: Boolean?): FS { override fun detect(cygwinUsed: Boolean?): FS {
return FS_POSIX_Java6_with_optional_symlinks() return FS_POSIX_Java6_with_optional_symlinks()
}
} }
}
private var repository: Repository? = null private var repository: Repository? = null
private val settings by lazy(LazyThreadSafetyMode.NONE) { Application.instance.sharedPrefs } private val settings by lazy(LazyThreadSafetyMode.NONE) { Application.instance.sharedPrefs }
private val filesDir private val filesDir
get() = Application.instance.filesDir get() = Application.instance.filesDir
/** /**
* Returns the git repository * Returns the git repository
* *
* @param localDir needed only on the creation * @param localDir needed only on the creation
* @return the git repository * @return the git repository
*/ */
@JvmStatic @JvmStatic
fun getRepository(localDir: File?): Repository? { fun getRepository(localDir: File?): Repository? {
if (repository == null && localDir != null) { if (repository == null && localDir != null) {
val builder = FileRepositoryBuilder() val builder = FileRepositoryBuilder()
repository = runCatching { repository =
builder.run { runCatching {
gitDir = localDir builder
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { .run {
fs = Java7FSFactory().detect(null) gitDir = localDir
} if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
readEnvironment() fs = Java7FSFactory().detect(null)
}.build() }
}.getOrElse { e -> readEnvironment()
e.printStackTrace()
null
} }
.build()
} }
return repository .getOrElse { e ->
e.printStackTrace()
null
}
} }
return repository
}
@JvmStatic @JvmStatic
val isInitialized: Boolean val isInitialized: Boolean
get() = repository != null get() = repository != null
@JvmStatic @JvmStatic
fun isGitRepo(): Boolean { fun isGitRepo(): Boolean {
if (repository != null) { if (repository != null) {
return repository!!.objectDatabase.exists() 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])
} }
return false if (remoteConfig.pushURIs.size > 0) {
} remoteConfig.removePushURI(remoteConfig.pushURIs[0])
@JvmStatic
@Throws(Exception::class)
fun createRepository(localDir: File) {
localDir.delete()
Git.init().setDirectory(localDir).call()
getRepository(localDir)
}
// TODO add multiple remotes support for pull/push
@JvmStatic
fun addRemote(name: String, url: String, replace: Boolean = false) {
val storedConfig = repository!!.config
val remotes = storedConfig.getSubsections("remote")
if (!remotes.contains(name)) {
runCatching {
val uri = URIish(url)
val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*")
val remoteConfig = RemoteConfig(storedConfig, name)
remoteConfig.addFetchRefSpec(refSpec)
remoteConfig.addPushRefSpec(refSpec)
remoteConfig.addURI(uri)
remoteConfig.addPushURI(uri)
remoteConfig.update(storedConfig)
storedConfig.save()
}.onFailure { e ->
e.printStackTrace()
}
} else if (replace) {
runCatching {
val uri = URIish(url)
val remoteConfig = RemoteConfig(storedConfig, name)
// remove the first and eventually the only uri
if (remoteConfig.urIs.size > 0) {
remoteConfig.removeURI(remoteConfig.urIs[0])
}
if (remoteConfig.pushURIs.size > 0) {
remoteConfig.removePushURI(remoteConfig.pushURIs[0])
}
remoteConfig.addURI(uri)
remoteConfig.addPushURI(uri)
remoteConfig.update(storedConfig)
storedConfig.save()
}.onFailure { e ->
e.printStackTrace()
}
} }
remoteConfig.addURI(uri)
remoteConfig.addPushURI(uri)
remoteConfig.update(storedConfig)
storedConfig.save()
}
.onFailure { e -> e.printStackTrace() }
}
}
@JvmStatic
fun closeRepository() {
if (repository != null) repository!!.close()
repository = null
}
@JvmStatic
fun getRepositoryDirectory(): File {
return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
if (externalRepo != null) File(externalRepo) else File(filesDir.toString(), "/store")
} else {
File(filesDir.toString(), "/store")
}
}
@JvmStatic
fun initialize(): Repository? {
val dir = getRepositoryDirectory()
// uninitialize the repo if the dir does not exist or is absolutely empty
settings.edit {
if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) {
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
} else {
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true)
}
} }
@JvmStatic // create the repository static variable in PasswordRepository
fun closeRepository() { return getRepository(File(dir.absolutePath + "/.git"))
if (repository != null) repository!!.close() }
repository = null
}
@JvmStatic /**
fun getRepositoryDirectory(): File { * Gets the .gpg files in a directory
return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { *
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) * @param path the directory path
if (externalRepo != null) * @return the list of gpg files in that directory
File(externalRepo) */
else @JvmStatic
File(filesDir.toString(), "/store") 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() })
}
passList.forEach { file ->
passwordList.add(
if (file.isFile) {
PasswordItem.newPassword(file.name, file, rootDir)
} else { } else {
File(filesDir.toString(), "/store") PasswordItem.newCategory(file.name, file, rootDir)
} }
)
} }
passwordList.sortWith(sortOrder.comparator)
@JvmStatic return passwordList
fun initialize(): Repository? { }
val dir = getRepositoryDirectory()
// uninitialize the repo if the dir does not exist or is absolutely empty
settings.edit {
if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) {
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
} else {
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true)
}
}
// create the repository static variable in PasswordRepository
return getRepository(File(dir.absolutePath + "/.git"))
}
/**
* Gets the .gpg files in a directory
*
* @param path the directory path
* @return the list of gpg files in that directory
*/
@JvmStatic
fun getFilesList(path: File?): ArrayList<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() })
}
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
}
} }

View file

@ -17,74 +17,74 @@ import dev.msfjarvis.aps.data.password.FieldItem
import dev.msfjarvis.aps.databinding.ItemFieldBinding import dev.msfjarvis.aps.databinding.ItemFieldBinding
class FieldItemAdapter( class FieldItemAdapter(
private var fieldItemList: List<FieldItem>, private var fieldItemList: List<FieldItem>,
private val showPassword: Boolean, private val showPassword: Boolean,
private val copyTextToClipBoard: (text: String?) -> Unit, private val copyTextToClipBoard: (text: String?) -> Unit,
) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() { ) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false) val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return FieldItemViewHolder(binding.root, binding) return FieldItemViewHolder(binding.root, binding)
} }
override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) { override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
holder.bind(fieldItemList[position], showPassword, copyTextToClipBoard) holder.bind(fieldItemList[position], showPassword, copyTextToClipBoard)
} }
override fun getItemCount(): Int { override fun getItemCount(): Int {
return fieldItemList.size return fieldItemList.size
} }
fun updateOTPCode(code: String) { fun updateOTPCode(code: String) {
var otpItemPosition = -1; var otpItemPosition = -1
fieldItemList = fieldItemList.mapIndexed { position, item -> fieldItemList =
if (item.key.equals(FieldItem.ItemType.OTP.type, true)) { fieldItemList.mapIndexed { position, item ->
otpItemPosition = position if (item.key.equals(FieldItem.ItemType.OTP.type, true)) {
return@mapIndexed FieldItem.createOtpField(code) otpItemPosition = position
} return@mapIndexed FieldItem.createOtpField(code)
return@mapIndexed item
} }
notifyItemChanged(otpItemPosition) return@mapIndexed item
} }
fun updateItems(itemList: List<FieldItem>) { notifyItemChanged(otpItemPosition)
fieldItemList = itemList }
notifyDataSetChanged()
}
class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : fun updateItems(itemList: List<FieldItem>) {
RecyclerView.ViewHolder(itemView) { fieldItemList = itemList
notifyDataSetChanged()
}
fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) { class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : RecyclerView.ViewHolder(itemView) {
with(binding) {
itemText.hint = fieldItem.key
itemTextContainer.hint = fieldItem.key
itemText.setText(fieldItem.value)
when (fieldItem.action) { fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) {
FieldItem.ActionType.COPY -> { with(binding) {
itemTextContainer.apply { itemText.hint = fieldItem.key
endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy) itemTextContainer.hint = fieldItem.key
endIconMode = TextInputLayout.END_ICON_CUSTOM itemText.setText(fieldItem.value)
setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
} when (fieldItem.action) {
} FieldItem.ActionType.COPY -> {
FieldItem.ActionType.HIDE -> { itemTextContainer.apply {
itemTextContainer.apply { endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE endIconMode = TextInputLayout.END_ICON_CUSTOM
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) } setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
}
itemText.apply {
if (!showPassword) {
transformationMethod = PasswordTransformationMethod.getInstance()
}
setOnClickListener { 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()) }
}
}
} }
}
} }
}
} }

View file

@ -19,65 +19,66 @@ import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter
import dev.msfjarvis.aps.util.viewmodel.stableId import dev.msfjarvis.aps.util.viewmodel.stableId
open class PasswordItemRecyclerAdapter : open class PasswordItemRecyclerAdapter :
SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>( SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>(
R.layout.password_row_layout, R.layout.password_row_layout,
::PasswordItemViewHolder, ::PasswordItemViewHolder,
PasswordItemViewHolder::bind PasswordItemViewHolder::bind
) { ) {
fun makeSelectable(recyclerView: RecyclerView) { fun makeSelectable(recyclerView: RecyclerView) {
makeSelectable(recyclerView, ::PasswordItemDetailsLookup) makeSelectable(recyclerView, ::PasswordItemDetailsLookup)
} }
override fun onItemClicked(listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit): PasswordItemRecyclerAdapter { override fun onItemClicked(
return super.onItemClicked(listener) as PasswordItemRecyclerAdapter listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit
} ): PasswordItemRecyclerAdapter {
return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
}
override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter { override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter {
return super.onSelectionChanged(listener) as 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 name: AppCompatTextView = itemView.findViewById(R.id.label)
private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count) private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count)
private val folderIndicator: AppCompatImageView = private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator)
itemView.findViewById(R.id.folder_indicator) lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String>
lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String>
fun bind(item: PasswordItem) { fun bind(item: PasswordItem) {
val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "")
val source = if (parentPath.isNotEmpty()) { val source =
"$parentPath\n$item" if (parentPath.isNotEmpty()) {
} else { "$parentPath\n$item"
"$item" } else {
} "$item"
val spannable = SpannableString(source) }
spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0) val spannable = SpannableString(source)
name.text = spannable spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0)
if (item.type == PasswordItem.TYPE_CATEGORY) { name.text = spannable
folderIndicator.visibility = View.VISIBLE if (item.type == PasswordItem.TYPE_CATEGORY) {
val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size folderIndicator.visibility = View.VISIBLE
?: 0 val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0
childCount.visibility = if (count > 0) View.VISIBLE else View.GONE childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
childCount.text = "$count" childCount.text = "$count"
} else { } else {
childCount.visibility = View.GONE childCount.visibility = View.GONE
folderIndicator.visibility = View.GONE folderIndicator.visibility = View.GONE
} }
itemDetails = object : ItemDetailsLookup.ItemDetails<String>() { itemDetails =
override fun getPosition() = absoluteAdapterPosition object : ItemDetailsLookup.ItemDetails<String>() {
override fun getSelectionKey() = item.stableId override fun getPosition() = absoluteAdapterPosition
} override fun getSelectionKey() = item.stableId
} }
} }
}
class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup<String>() {
ItemDetailsLookup<String>() {
override fun getItemDetails(event: MotionEvent): ItemDetails<String>? { override fun getItemDetails(event: MotionEvent): ItemDetails<String>? {
val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null
return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails
}
} }
}
} }

View file

@ -51,195 +51,184 @@ import org.openintents.openpgp.OpenPgpError
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope { 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_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH"
private const val EXTRA_SEARCH_ACTION = private const val EXTRA_SEARCH_ACTION = "dev.msfjarvis.aps.autofill.oreo.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 { fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
return Intent(context, AutofillDecryptActivity::class.java).apply { return Intent(context, AutofillDecryptActivity::class.java).apply {
putExtras(forwardedExtras) putExtras(forwardedExtras)
putExtra(EXTRA_SEARCH_ACTION, true) putExtra(EXTRA_SEARCH_ACTION, true)
putExtra(EXTRA_FILE_PATH, file.absolutePath) putExtra(EXTRA_FILE_PATH, file.absolutePath)
} }
}
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
val intent = Intent(context, AutofillDecryptActivity::class.java).apply {
putExtra(EXTRA_SEARCH_ACTION, false)
putExtra(EXTRA_FILE_PATH, file.absolutePath)
}
return PendingIntent.getActivity(
context,
decryptFileRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
).intentSender
}
} }
private val decryptInteractionRequiredAction = registerForActivityResult(StartIntentSenderForResult()) { result -> fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
if (continueAfterUserInteraction != null) { val intent =
val data = result.data Intent(context, AutofillDecryptActivity::class.java).apply {
if (result.resultCode == RESULT_OK && data != null) { putExtra(EXTRA_SEARCH_ACTION, false)
continueAfterUserInteraction?.resume(data) putExtra(EXTRA_FILE_PATH, file.absolutePath)
} else {
continueAfterUserInteraction?.resumeWithException(Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction"))
}
continueAfterUserInteraction = null
} }
return PendingIntent.getActivity(context, decryptFileRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
.intentSender
}
}
private val decryptInteractionRequiredAction =
registerForActivityResult(StartIntentSenderForResult()) { result ->
if (continueAfterUserInteraction != null) {
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
continueAfterUserInteraction?.resume(data)
} else {
continueAfterUserInteraction?.resumeWithException(
Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")
)
}
continueAfterUserInteraction = null
}
} }
private var continueAfterUserInteraction: Continuation<Intent>? = null private var continueAfterUserInteraction: Continuation<Intent>? = null
private lateinit var directoryStructure: DirectoryStructure private lateinit var directoryStructure: DirectoryStructure
override val coroutineContext override val coroutineContext
get() = Dispatchers.IO + SupervisorJob() get() = Dispatchers.IO + SupervisorJob()
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run { val filePath =
e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" } intent?.getStringExtra(EXTRA_FILE_PATH)
finish() ?: run {
return e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
finish()
return
} }
val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { val clientState =
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
finish() ?: run {
return e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
finish()
return
} }
val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
directoryStructure = AutofillPreferences.directoryStructure(this) directoryStructure = AutofillPreferences.directoryStructure(this)
d { action.toString() } d { action.toString() }
launch { launch {
val credentials = decryptCredential(File(filePath)) val credentials = decryptCredential(File(filePath))
if (credentials == null) { if (credentials == null) {
setResult(RESULT_CANCELED) setResult(RESULT_CANCELED)
} else { } else {
val fillInDataset = val fillInDataset =
AutofillResponseBuilder.makeFillInDataset( AutofillResponseBuilder.makeFillInDataset(this@AutofillDecryptActivity, credentials, clientState, action)
this@AutofillDecryptActivity, withContext(Dispatchers.Main) {
credentials, setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) })
clientState, }
action }
) withContext(Dispatchers.Main) { finish() }
withContext(Dispatchers.Main) { }
setResult(RESULT_OK, Intent().apply { }
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
}) 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)
} }
}
withContext(Dispatchers.Main) {
finish()
}
}
}
override fun onDestroy() { override fun onError(e: Exception) {
super.onDestroy() cont.resumeWithException(e)
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)
}
override fun onError(e: Exception) {
cont.resumeWithException(e)
}
}).also { it.bindToService() }
}
return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
openPgpServiceConnection?.unbindFromService()
}
}
private suspend fun decryptCredential(
file: File,
resumeIntent: Intent? = null
): Credentials? {
val command = resumeIntent ?: Intent().apply {
action = OpenPgpApi.ACTION_DECRYPT_VERIFY
}
runCatching {
file.inputStream()
}.onFailure { e ->
e(e) { "File to decrypt not found" }
return null
}.onSuccess { encryptedInput ->
val decryptedOutput = ByteArrayOutputStream()
runCatching {
executeOpenPgpApi(command, encryptedInput, decryptedOutput)
}.onFailure { e ->
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" }
return null
}.onSuccess { result ->
return when (val resultCode =
result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
OpenPgpApi.RESULT_CODE_SUCCESS -> {
runCatching {
val entry = withContext(Dispatchers.IO) {
@Suppress("BlockingMethodInNonBlockingContext")
(PasswordEntry(decryptedOutput))
}
AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
}.getOrElse { e ->
e(e) { "Failed to parse password entry" }
return null
}
}
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
val pendingIntent: PendingIntent =
result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!
runCatching {
val intentToResume = withContext(Dispatchers.Main) {
suspendCoroutine<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
}
} }
} }
} )
.also { it.bindToService() }
}
return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
openPgpServiceConnection?.unbindFromService()
}
}
private suspend fun decryptCredential(file: File, resumeIntent: Intent? = null): Credentials? {
val command = resumeIntent ?: Intent().apply { action = OpenPgpApi.ACTION_DECRYPT_VERIFY }
runCatching { file.inputStream() }
.onFailure { e ->
e(e) { "File to decrypt not found" }
return null 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
}
}
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
}
} }

View file

@ -41,180 +41,164 @@ import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel
@TargetApi(Build.VERSION_CODES.O) @TargetApi(Build.VERSION_CODES.O)
class AutofillFilterView : AppCompatActivity() { class AutofillFilterView : AppCompatActivity() {
companion object { companion object {
private const val HEIGHT_PERCENTAGE = 0.9 private const val HEIGHT_PERCENTAGE = 0.9
private const val WIDTH_PERCENTAGE = 0.75 private const val WIDTH_PERCENTAGE = 0.75
private const val EXTRA_FORM_ORIGIN_WEB = private const val EXTRA_FORM_ORIGIN_WEB = "dev.msfjarvis.aps.autofill.oreo.ui.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 const val EXTRA_FORM_ORIGIN_APP = private var matchAndDecryptFileRequestCode = 1
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
private var matchAndDecryptFileRequestCode = 1
fun makeMatchAndDecryptFileIntentSender( fun makeMatchAndDecryptFileIntentSender(context: Context, formOrigin: FormOrigin): IntentSender {
context: Context, val intent =
formOrigin: FormOrigin Intent(context, AutofillFilterView::class.java).apply {
): IntentSender { when (formOrigin) {
val intent = Intent(context, AutofillFilterView::class.java).apply { is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier)
when (formOrigin) { is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
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
} }
} return PendingIntent.getActivity(
context,
private lateinit var formOrigin: FormOrigin matchAndDecryptFileRequestCode++,
private lateinit var directoryStructure: DirectoryStructure intent,
private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate) PendingIntent.FLAG_CANCEL_CURRENT
private val model: SearchableRepositoryViewModel by viewModels {
ViewModelProvider.AndroidViewModelFactory(application)
}
private val decryptAction = registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
setResult(RESULT_OK, result.data)
}
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setFinishOnTouchOutside(true)
val params = window.attributes
params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt()
params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
window.attributes = params
if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
finish()
return
}
formOrigin = when {
intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> {
FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!)
}
intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> {
FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
}
else -> {
e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" }
finish()
return
}
}
directoryStructure = AutofillPreferences.directoryStructure(this)
supportActionBar?.hide()
bindUI()
updateSearch()
setResult(RESULT_CANCELED)
}
private fun bindUI() {
with(binding) {
rvPassword.apply {
adapter = SearchableRepositoryAdapter(
R.layout.oreo_autofill_filter_row,
::PasswordViewHolder
) { item ->
val file = item.file.relativeTo(item.rootDir)
val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
val identifier = directoryStructure.getIdentifierFor(file)
val accountPart = directoryStructure.getAccountPartFor(file)
check(identifier != null || accountPart != null) { "At least one of identifier and accountPart should always be non-null" }
title.text = if (identifier != null) {
buildSpannedString {
if (pathToIdentifier != null)
append("$pathToIdentifier/")
bold { underline { append(identifier) } }
}
} else {
accountPart
}
subtitle.apply {
if (identifier != null && accountPart != null) {
text = accountPart
visibility = View.VISIBLE
} else {
visibility = View.GONE
}
}
}.onItemClicked { _, item ->
decryptAndFill(item)
}
layoutManager = LinearLayoutManager(context)
}
search.apply {
val initialSearch =
formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
setText(initialSearch, TextView.BufferType.EDITABLE)
addTextChangedListener { updateSearch() }
}
origin.text = buildSpannedString {
append(getString(R.string.oreo_autofill_select_and_fill_into))
append("\n")
bold {
append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true))
}
}
strictDomainSearch.apply {
visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE
isChecked = formOrigin is FormOrigin.Web
setOnCheckedChangeListener { _, _ -> updateSearch() }
}
shouldMatch.text = getString(
R.string.oreo_autofill_match_with,
formOrigin.getPrettyIdentifier(applicationContext)
)
model.searchResult.observe(this@AutofillFilterView) { result ->
val list = result.passwordItems
(rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) {
rvPassword.scrollToPosition(0)
}
// Switch RecyclerView out for a "no results" message if the new list is empty and
// the message is not yet shown (and vice versa).
if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) ||
(list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id)
) {
rvPasswordSwitcher.showNext()
}
}
}
}
private fun updateSearch() {
model.search(
binding.search.text.toString().trim(),
filterMode = if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy,
searchMode = SearchMode.RecursivelyInSubdirectories,
listMode = ListMode.FilesOnly
) )
.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 fun decryptAndFill(item: PasswordItem) { override fun onCreate(savedInstanceState: Bundle?) {
if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) super.onCreate(savedInstanceState)
if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor( setContentView(binding.root)
applicationContext, setFinishOnTouchOutside(true)
formOrigin,
item.file val params = window.attributes
) params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt()
// intent?.extras? is checked to be non-null in onCreate params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent( window.attributes = params
item.file,
intent!!.extras!!, if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
this e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
)) finish()
return
} }
formOrigin =
when {
intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> {
FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!)
}
intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> {
FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
}
else -> {
e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" }
finish()
return
}
}
directoryStructure = AutofillPreferences.directoryStructure(this)
supportActionBar?.hide()
bindUI()
updateSearch()
setResult(RESULT_CANCELED)
}
private fun bindUI() {
with(binding) {
rvPassword.apply {
adapter =
SearchableRepositoryAdapter(R.layout.oreo_autofill_filter_row, ::PasswordViewHolder) { item ->
val file = item.file.relativeTo(item.rootDir)
val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
val identifier = directoryStructure.getIdentifierFor(file)
val accountPart = directoryStructure.getAccountPartFor(file)
check(identifier != null || accountPart != null) {
"At least one of identifier and accountPart should always be non-null"
}
title.text =
if (identifier != null) {
buildSpannedString {
if (pathToIdentifier != null) append("$pathToIdentifier/")
bold { underline { append(identifier) } }
}
} else {
accountPart
}
subtitle.apply {
if (identifier != null && accountPart != null) {
text = accountPart
visibility = View.VISIBLE
} else {
visibility = View.GONE
}
}
}
.onItemClicked { _, item -> decryptAndFill(item) }
layoutManager = LinearLayoutManager(context)
}
search.apply {
val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
setText(initialSearch, TextView.BufferType.EDITABLE)
addTextChangedListener { updateSearch() }
}
origin.text =
buildSpannedString {
append(getString(R.string.oreo_autofill_select_and_fill_into))
append("\n")
bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) }
}
strictDomainSearch.apply {
visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE
isChecked = formOrigin is FormOrigin.Web
setOnCheckedChangeListener { _, _ -> updateSearch() }
}
shouldMatch.text =
getString(R.string.oreo_autofill_match_with, formOrigin.getPrettyIdentifier(applicationContext))
model.searchResult.observe(this@AutofillFilterView) { result ->
val list = result.passwordItems
(rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { rvPassword.scrollToPosition(0) }
// Switch RecyclerView out for a "no results" message if the new list is empty and
// the message is not yet shown (and vice versa).
if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) ||
(list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id)
) {
rvPasswordSwitcher.showNext()
}
}
}
}
private fun updateSearch() {
model.search(
binding.search.text.toString().trim(),
filterMode = if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy,
searchMode = SearchMode.RecursivelyInSubdirectories,
listMode = ListMode.FilesOnly
)
}
private fun decryptAndFill(item: PasswordItem) {
if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file)
// intent?.extras? is checked to be non-null in onCreate
decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this))
}
} }

View file

@ -31,84 +31,83 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
@TargetApi(Build.VERSION_CODES.O) @TargetApi(Build.VERSION_CODES.O)
class AutofillPublisherChangedActivity : AppCompatActivity() { class AutofillPublisherChangedActivity : AppCompatActivity() {
companion object { companion object {
private const val EXTRA_APP_PACKAGE = private const val EXTRA_APP_PACKAGE = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE"
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE" private const val EXTRA_FILL_RESPONSE_AFTER_RESET =
private const val EXTRA_FILL_RESPONSE_AFTER_RESET = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET"
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET" private var publisherChangedRequestCode = 1
private var publisherChangedRequestCode = 1
fun makePublisherChangedIntentSender( fun makePublisherChangedIntentSender(
context: Context, context: Context,
publisherChangedException: AutofillPublisherChangedException, publisherChangedException: AutofillPublisherChangedException,
fillResponseAfterReset: FillResponse?, fillResponseAfterReset: FillResponse?,
): IntentSender { ): IntentSender {
val intent = Intent(context, AutofillPublisherChangedActivity::class.java).apply { val intent =
putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier) Intent(context, AutofillPublisherChangedActivity::class.java).apply {
putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset) 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
} }
return PendingIntent.getActivity(
context,
publisherChangedRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
)
.intentSender
} }
}
private lateinit var appPackage: String private lateinit var appPackage: String
private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate) private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
setFinishOnTouchOutside(true) setFinishOnTouchOutside(true)
appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run { appPackage =
e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" } intent.getStringExtra(EXTRA_APP_PACKAGE)
finish() ?: run {
return e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
} finish()
supportActionBar?.hide() return
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()
}
} }
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() { private fun showPackageInfo() {
runCatching { runCatching {
with(binding) { with(binding) {
val packageInfo = val packageInfo = packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA) val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime) warningAppInstallDate.text = getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
warningAppInstallDate.text = val appInfo = packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
getString(R.string.oreo_autofill_warning_publisher_install_time, installTime) warningAppName.text = "${packageManager.getApplicationLabel(appInfo)}"
val appInfo =
packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
warningAppName.text = "${packageManager.getApplicationLabel(appInfo)}"
val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage) val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage)
warningAppAdvancedInfo.text = getString( warningAppAdvancedInfo.text =
R.string.oreo_autofill_warning_publisher_advanced_info_template, getString(R.string.oreo_autofill_warning_publisher_advanced_info_template, appPackage, currentHash)
appPackage, }
currentHash
)
}
}.onFailure { e ->
e(e) { "Failed to retrieve package info for $appPackage" }
finish()
}
} }
.onFailure { e ->
e(e) { "Failed to retrieve package info for $appPackage" }
finish()
}
}
} }

View file

@ -29,121 +29,106 @@ import java.io.File
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
class AutofillSaveActivity : AppCompatActivity() { class AutofillSaveActivity : AppCompatActivity() {
companion object { companion object {
private const val EXTRA_FOLDER_NAME = private const val EXTRA_FOLDER_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.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_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_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_APP = private const val EXTRA_SHOULD_MATCH_WEB = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP" private const val EXTRA_GENERATE_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
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( fun makeSaveIntentSender(context: Context, credentials: Credentials?, formOrigin: FormOrigin): IntentSender {
context: Context, val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
credentials: Credentials?, // Prevent directory traversals
formOrigin: FormOrigin val sanitizedIdentifier =
): IntentSender { identifier.replace('\\', '_').replace('/', '_').trimStart('.').takeUnless { it.isBlank() }
val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false) ?: formOrigin.identifier
// Prevent directory traversals val directoryStructure = AutofillPreferences.directoryStructure(context)
val sanitizedIdentifier = identifier.replace('\\', '_') val folderName =
.replace('/', '_') directoryStructure.getSaveFolderName(
.trimStart('.') sanitizedIdentifier = sanitizedIdentifier,
.takeUnless { it.isBlank() } ?: formOrigin.identifier username = credentials?.username
val directoryStructure = AutofillPreferences.directoryStructure(context) )
val folderName = directoryStructure.getSaveFolderName( val fileName = directoryStructure.getSaveFileName(username = credentials?.username, identifier = identifier)
sanitizedIdentifier = sanitizedIdentifier, val intent =
username = credentials?.username 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) { private val formOrigin by lazy(LazyThreadSafetyMode.NONE) {
val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP) val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP)
val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB) val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB)
if (shouldMatchApp != null && shouldMatchWeb == null) { if (shouldMatchApp != null && shouldMatchWeb == null) {
FormOrigin.App(shouldMatchApp) FormOrigin.App(shouldMatchApp)
} else if (shouldMatchApp == null && shouldMatchWeb != null) { } else if (shouldMatchApp == null && shouldMatchWeb != null) {
FormOrigin.Web(shouldMatchWeb) FormOrigin.Web(shouldMatchWeb)
} else { } else {
null null
}
} }
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val repo = PasswordRepository.getRepositoryDirectory() val repo = PasswordRepository.getRepositoryDirectory()
val saveIntent = Intent(this, PasswordCreationActivity::class.java).apply { val saveIntent =
putExtras( Intent(this, PasswordCreationActivity::class.java).apply {
bundleOf( putExtras(
"REPO_PATH" to repo.absolutePath, bundleOf(
"FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath, "REPO_PATH" to repo.absolutePath,
PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME), "FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD), PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) 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 registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK && data != null) { val data = result.data
val createdPath = data.getStringExtra("CREATED_FILE")!! if (result.resultCode == RESULT_OK && data != null) {
formOrigin?.let { val createdPath = data.getStringExtra("CREATED_FILE")!!
AutofillMatcher.addMatchFor(this, it, File(createdPath)) formOrigin?.let { AutofillMatcher.addMatchFor(this, it, File(createdPath)) }
} val password = data.getStringExtra("PASSWORD")
val password = data.getStringExtra("PASSWORD") val resultIntent =
val resultIntent = if (password != null) { if (password != null) {
// Password was generated and should be filled into a form. // Password was generated and should be filled into a form.
val username = data.getStringExtra("USERNAME") val username = data.getStringExtra("USERNAME")
val clientState = val clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } ?: run {
finish() e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
return@registerForActivityResult finish()
} return@registerForActivityResult
val credentials = Credentials(username, password, null) }
val fillInDataset = AutofillResponseBuilder.makeFillInDataset( val credentials = Credentials(username, password, null)
this, val fillInDataset =
credentials, AutofillResponseBuilder.makeFillInDataset(this, credentials, clientState, AutofillAction.Generate)
clientState, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }
AutofillAction.Generate
)
Intent().apply {
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
}
} else {
// Password was extracted from a form, there is nothing to fill.
Intent()
}
setResult(RESULT_OK, resultIntent)
} else { } else {
setResult(RESULT_CANCELED) // Password was extracted from a form, there is nothing to fill.
Intent()
} }
finish() setResult(RESULT_OK, resultIntent)
}.launch(saveIntent) } else {
} setResult(RESULT_CANCELED)
}
finish()
}
.launch(saveIntent)
}
} }

View file

@ -11,6 +11,6 @@ import dev.msfjarvis.aps.R
class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) { class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val title: TextView = itemView.findViewById(R.id.title) val title: TextView = itemView.findViewById(R.id.title)
val subtitle: TextView = itemView.findViewById(R.id.subtitle) val subtitle: TextView = itemView.findViewById(R.id.subtitle)
} }

View file

@ -42,269 +42,249 @@ import org.openintents.openpgp.OpenPgpError
@Suppress("Registered") @Suppress("Registered")
open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
/** /** Full path to the repository */
* Full path to the repository val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! }
*/
val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! }
/** /** Full path to the password file being worked on */
* Full path to the password file being worked on val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! }
*/
val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! }
/** /**
* Name of the password file * Name of the password file
* *
* Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org * 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 } val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension }
/** /** Get the timestamp for when this file was last modified. */
* Get the timestamp for when this file was last modified. val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) {
*/ getLastChangedString(intent.getLongExtra("LAST_CHANGED_TIMESTAMP", -1L))
val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) { }
getLastChangedString(
intent.getLongExtra( /** [SharedPreferences] instance used by subclasses to persist settings */
"LAST_CHANGED_TIMESTAMP", val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs }
-1L
) /**
) * Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain.
*/
private var serviceConnection: OpenPgpServiceConnection? = null
var api: OpenPgpApi? = null
/**
* A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with
* in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package.
*/
private var previousListener: OpenPgpServiceConnection.OnBound? = null
/**
* [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or
* recent apps screen.
*/
@CallSuper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
tag(TAG)
}
/**
* [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This is
* annotated with [CallSuper] because it's critical to unbind the service to ensure we're not
* leaking things.
*/
@CallSuper
override fun onDestroy() {
super.onDestroy()
serviceConnection?.unbindFromService()
previousListener = null
}
/**
* [onResume] controls the flow for resumption of a PGP operation that was previously interrupted
* by the [OPENPGP_PROVIDER] package being missing.
*/
override fun onResume() {
super.onResume()
previousListener?.let { bindToOpenKeychain(it) }
}
/**
* Sets up [api] once the service is bound. Downstream consumers must call super this to
* initialize [api]
*/
@CallSuper
override fun onBound(service: IOpenPgpService2) {
api = OpenPgpApi(this, service)
}
/**
* Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle
* their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call
* super.
*/
override fun onError(e: Exception) {
e(e) { "Callers must handle their own exceptions" }
throw e
}
/** Method for subclasses to initiate binding with [OpenPgpServiceConnection]. */
fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) {
val installed =
runCatching {
packageManager.getPackageInfo(OPENPGP_PROVIDER, 0)
true
}
.getOr(false)
if (!installed) {
previousListener = onBoundListener
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.openkeychain_not_installed_title))
.setMessage(getString(R.string.openkeychain_not_installed_message))
.setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ ->
runCatching {
val intent =
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER))
setPackage("com.android.vending")
}
startActivity(intent)
}
}
.setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ ->
runCatching {
val intent =
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER))
}
startActivity(intent)
}
}
.setOnCancelListener { finish() }
.show()
return
} else {
previousListener = null
serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { it.bindToService() }
}
}
/**
* Handle the case where OpenKeychain returns that it needs to interact with the user
*
* @param result The intent returned by OpenKeychain
*/
fun getUserInteractionRequestIntent(result: Intent): IntentSender {
i { "RESULT_CODE_USER_INTERACTION_REQUIRED" }
return result.getParcelableExtra<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()
} }
/** return DateUtils.getRelativeTimeSpanString(this, timeStamp, true)
* [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. * 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.
private var serviceConnection: OpenPgpServiceConnection? = null */
var api: OpenPgpApi? = null 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}" }
}
}
}
}
/** /**
* A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with * Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing
* in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package. * [showSnackbar] as false.
*/ */
private var previousListener: OpenPgpServiceConnection.OnBound? = null 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))
}
}
/** /**
* [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to hide
* or recent apps screen. * the default [Snackbar] and starts off an instance of [ClipboardService] to provide a way of
*/ * clearing the clipboard.
@CallSuper */
override fun onCreate(savedInstanceState: Bundle?) { fun copyPasswordToClipboard(password: String?) {
super.onCreate(savedInstanceState) copyTextToClipboard(password, showSnackbar = false)
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
tag(TAG) val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45
if (clearAfter != 0) {
val service =
Intent(this, ClipboardService::class.java).apply {
action = ClipboardService.ACTION_START
putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(service)
} else {
startService(service)
}
snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter))
} else {
snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text))
}
}
companion object {
private const val TAG = "APS/BasePgpActivity"
const val KEY_PWGEN_TYPE_CLASSIC = "classic"
const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
/** Gets the relative path to the repository */
fun getRelativePath(fullPath: String, repositoryPath: String): String =
fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
/** Gets the Parent path, relative to the repository */
fun getParentPath(fullPath: String, repositoryPath: String): String {
val relativePath = getRelativePath(fullPath, repositoryPath)
val index = relativePath.lastIndexOf("/")
return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/")
} }
/** /** /path/to/store/social/facebook.gpg -> social/facebook */
* [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This @JvmStatic
* is annotated with [CallSuper] because it's critical to unbind the service to ensure we're not fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
* leaking things. var relativePath = getRelativePath(fullPath, repositoryPath)
*/ return if (relativePath.isNotEmpty() && relativePath != "/") {
@CallSuper // remove preceding '/'
override fun onDestroy() { relativePath = relativePath.substring(1)
super.onDestroy() if (relativePath.endsWith('/')) {
serviceConnection?.unbindFromService() relativePath + basename
previousListener = null
}
/**
* [onResume] controls the flow for resumption of a PGP operation that was previously interrupted
* by the [OPENPGP_PROVIDER] package being missing.
*/
override fun onResume() {
super.onResume()
previousListener?.let { bindToOpenKeychain(it) }
}
/**
* Sets up [api] once the service is bound. Downstream consumers must call super this to
* initialize [api]
*/
@CallSuper
override fun onBound(service: IOpenPgpService2) {
api = OpenPgpApi(this, service)
}
/**
* Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle
* their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call super.
*/
override fun onError(e: Exception) {
e(e) { "Callers must handle their own exceptions" }
throw e
}
/**
* Method for subclasses to initiate binding with [OpenPgpServiceConnection].
*/
fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) {
val installed = runCatching {
packageManager.getPackageInfo(OPENPGP_PROVIDER, 0)
true
}.getOr(false)
if (!installed) {
previousListener = onBoundListener
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.openkeychain_not_installed_title))
.setMessage(getString(R.string.openkeychain_not_installed_message))
.setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ ->
runCatching {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER))
setPackage("com.android.vending")
}
startActivity(intent)
}
}
.setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ ->
runCatching {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER))
}
startActivity(intent)
}
}
.setOnCancelListener { finish() }
.show()
return
} else { } else {
previousListener = null "$relativePath/$basename"
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()
}
return DateUtils.getRelativeTimeSpanString(this, timeStamp, true)
}
/**
* Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses
* can use this when they want to default to sane error handling.
*/
fun handleError(result: Intent) {
val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR)
if (error != null) {
when (error.errorId) {
OpenPgpError.NO_OR_WRONG_PASSPHRASE -> {
snackbar(message = getString(R.string.openpgp_error_wrong_passphrase))
}
OpenPgpError.NO_USER_IDS -> {
snackbar(message = getString(R.string.openpgp_error_no_user_ids))
}
else -> {
snackbar(message = getString(R.string.openpgp_error_unknown, error.message))
e { "onError getErrorId: ${error.errorId}" }
e { "onError getMessage: ${error.message}" }
}
}
}
}
/**
* Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing
* [showSnackbar] as false.
*/
fun copyTextToClipboard(
text: String?,
showSnackbar: Boolean = true,
@StringRes snackbarTextRes: Int = R.string.clipboard_copied_text
) {
val clipboard = clipboard ?: return
val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
clipboard.setPrimaryClip(clip)
if (showSnackbar) {
snackbar(message = resources.getString(snackbarTextRes))
}
}
/**
* Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to
* hide the default [Snackbar] and starts off an instance of [ClipboardService] to provide a
* way of clearing the clipboard.
*/
fun copyPasswordToClipboard(password: String?) {
copyTextToClipboard(password, showSnackbar = false)
val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45
if (clearAfter != 0) {
val service = Intent(this, ClipboardService::class.java).apply {
action = ClipboardService.ACTION_START
putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(service)
} else {
startService(service)
}
snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter))
} else {
snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text))
}
}
companion object {
private const val TAG = "APS/BasePgpActivity"
const val KEY_PWGEN_TYPE_CLASSIC = "classic"
const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
/**
* Gets the relative path to the repository
*/
fun getRelativePath(fullPath: String, repositoryPath: String): String =
fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
/**
* Gets the Parent path, relative to the repository
*/
fun getParentPath(fullPath: String, repositoryPath: String): String {
val relativePath = getRelativePath(fullPath, repositoryPath)
val index = relativePath.lastIndexOf("/")
return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/")
}
/**
* /path/to/store/social/facebook.gpg -> social/facebook
*/
@JvmStatic
fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
var relativePath = getRelativePath(fullPath, repositoryPath)
return if (relativePath.isNotEmpty() && relativePath != "/") {
// remove preceding '/'
relativePath = relativePath.substring(1)
if (relativePath.endsWith('/')) {
relativePath + basename
} else {
"$relativePath/$basename"
}
} else {
basename
}
} }
} else {
basename
}
} }
}
} }

View file

@ -37,202 +37,196 @@ import org.openintents.openpgp.IOpenPgpService2
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { 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 val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
private var passwordEntry: PasswordEntry? = null private var passwordEntry: PasswordEntry? = null
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> private val userInteractionRequiredResult =
if (result.data == null) { registerForActivityResult(StartIntentSenderForResult()) { result ->
setResult(RESULT_CANCELED, null) if (result.data == null) {
finish() setResult(RESULT_CANCELED, null)
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 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
}
override fun onBound(service: IOpenPgpService2) {
super.onBound(service)
decryptAndVerify()
}
override fun onError(e: Exception) {
e(e)
}
/**
* Automatically finishes the activity 60 seconds after decryption succeeded to prevent
* information leaks from stale activities.
*/
@OptIn(ExperimentalTime::class)
private fun startAutoDismissTimer() {
lifecycleScope.launch {
delay(60.seconds)
finish()
}
}
/**
* Edit the current password and hide all the fields populated by encrypted data so that when
* the result triggers they can be repopulated with new data.
*/
private fun editPassword() {
val intent = Intent(this, PasswordCreationActivity::class.java)
intent.putExtra("FILE_PATH", relativeParentPath)
intent.putExtra("REPO_PATH", repoPath)
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent)
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
startActivity(intent)
finish() finish()
return@registerForActivityResult
}
when (result.resultCode) {
RESULT_OK -> decryptAndVerify(result.data)
RESULT_CANCELED -> {
setResult(RESULT_CANCELED, result.data)
finish()
}
}
} }
private fun shareAsPlaintext() { override fun onCreate(savedInstanceState: Bundle?) {
val sendIntent = Intent().apply { super.onCreate(savedInstanceState)
action = Intent.ACTION_SEND supportActionBar?.setDisplayHomeAsUpEnabled(true)
putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) bindToOpenKeychain(this)
type = "text/plain" 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
} }
// 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) override fun onCreateOptionsMenu(menu: Menu?): Boolean {
private fun decryptAndVerify(receivedIntent: Intent? = null) { menuInflater.inflate(R.menu.pgp_handler, menu)
if (api == null) { passwordEntry?.let { entry ->
bindToOpenKeychain(this) if (menu != null) {
return 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
} }
val data = receivedIntent ?: Intent() }
data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY }
return true
}
val inputStream = File(fullPath).inputStream() override fun onOptionsItemSelected(item: MenuItem): Boolean {
val outputStream = ByteArrayOutputStream() 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
}
lifecycleScope.launch(Dispatchers.IO) { override fun onBound(service: IOpenPgpService2) {
api?.executeApiAsync(data, inputStream, outputStream) { result -> super.onBound(service)
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { decryptAndVerify()
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)) { override fun onError(e: Exception) {
copyPasswordToClipboard(entry.password) e(e)
} }
passwordEntry = entry /**
invalidateOptionsMenu() * 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()
}
}
if (entry.password.isNotEmpty()) { /**
items.add(FieldItem.createPasswordField(entry.password)) * 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()
}
if (entry.hasTotp()) { private fun shareAsPlaintext() {
launch(Dispatchers.IO) { val sendIntent =
// Calculate the actual remaining time for the first pass Intent().apply {
// then return to the standard 30 second affair. action = Intent.ACTION_SEND
val remainingTime = putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod) type = "text/plain"
withContext(Dispatchers.Main) { }
val code = entry.calculateTotpCode() ?: "Error" // Always show a picker to give the user a chance to cancel
items.add(FieldItem.createOtpField(code)) startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)))
} }
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()) { @OptIn(ExperimentalTime::class)
items.add(FieldItem.createUsernameField(entry.username)) private fun decryptAndVerify(receivedIntent: Intent? = null) {
} if (api == null) {
bindToOpenKeychain(this)
return
}
val data = receivedIntent ?: Intent()
data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY
if (entry.hasExtraContentWithoutAuthData()) { val inputStream = File(fullPath).inputStream()
entry.extraContentMap.forEach { (key, value) -> val outputStream = ByteArrayOutputStream()
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
}
}
binding.recyclerView.adapter = adapter lifecycleScope.launch(Dispatchers.IO) {
adapter.updateItems(items) api?.executeApiAsync(data, inputStream, outputStream) { result ->
}.onFailure { e -> when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
e(e) OpenPgpApi.RESULT_CODE_SUCCESS -> {
} startAutoDismissTimer()
} runCatching {
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
val sender = getUserInteractionRequestIntent(result) val entry = PasswordEntry(outputStream)
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build()) val items = arrayListOf<FieldItem>()
} val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> copyTextToClipboard(text) }
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
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)
} }
}
} }
}
} }

View file

@ -21,56 +21,54 @@ import org.openintents.openpgp.IOpenPgpService2
class GetKeyIdsActivity : BasePgpActivity() { class GetKeyIdsActivity : BasePgpActivity() {
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> private val userInteractionRequiredResult =
if (result.data == null || result.resultCode == RESULT_CANCELED) { registerForActivityResult(StartIntentSenderForResult()) { result ->
setResult(RESULT_CANCELED, result.data) if (result.data == null || result.resultCode == RESULT_CANCELED) {
finish() setResult(RESULT_CANCELED, result.data)
return@registerForActivityResult finish()
} return@registerForActivityResult
getKeyIds(result.data!!) }
getKeyIds(result.data!!)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
bindToOpenKeychain(this) bindToOpenKeychain(this)
} }
override fun onBound(service: IOpenPgpService2) { override fun onBound(service: IOpenPgpService2) {
super.onBound(service) super.onBound(service)
getKeyIds() getKeyIds()
} }
override fun onError(e: Exception) { override fun onError(e: Exception) {
e(e) e(e)
} }
/** /** Get the Key ids from OpenKeychain */
* Get the Key ids from OpenKeychain private fun getKeyIds(data: Intent = Intent()) {
*/ data.action = OpenPgpApi.ACTION_GET_KEY_IDS
private fun getKeyIds(data: Intent = Intent()) { lifecycleScope.launch(Dispatchers.IO) {
data.action = OpenPgpApi.ACTION_GET_KEY_IDS api?.executeApiAsync(data, null, null) { result ->
lifecycleScope.launch(Dispatchers.IO) { when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
api?.executeApiAsync(data, null, null) { result -> OpenPgpApi.RESULT_CODE_SUCCESS -> {
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { runCatching {
OpenPgpApi.RESULT_CODE_SUCCESS -> { val ids =
runCatching { result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { OpenPgpUtils.convertKeyIdToHex(it) }
val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { ?: emptyList()
OpenPgpUtils.convertKeyIdToHex(it) val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray())
} ?: emptyList() setResult(RESULT_OK, keyResult)
val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray()) finish()
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)
}
} }
.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)
} }
}
} }
}
} }

View file

@ -55,454 +55,443 @@ import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound { class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
private val binding by viewBinding(PasswordCreationActivityBinding::inflate) private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } 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 suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } 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 shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) {
private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) } intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } }
private var oldCategory: String? = null private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) }
private var copy: Boolean = false private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
private var encryptionIntent: Intent = Intent() private var oldCategory: String? = null
private var copy: Boolean = false
private var encryptionIntent: Intent = Intent()
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> private val userInteractionRequiredResult =
if (result.data == null) { registerForActivityResult(StartIntentSenderForResult()) { result ->
setResult(RESULT_CANCELED, null) if (result.data == null) {
finish() setResult(RESULT_CANCELED, null)
return@registerForActivityResult finish()
} return@registerForActivityResult
}
when (result.resultCode) {
RESULT_OK -> encrypt(result.data) when (result.resultCode) {
RESULT_CANCELED -> { RESULT_OK -> encrypt(result.data)
setResult(RESULT_CANCELED, result.data) RESULT_CANCELED -> {
finish() setResult(RESULT_CANCELED, result.data)
} finish()
} }
}
} }
private val otpImportAction = registerForActivityResult(StartActivityForResult()) { result -> private val otpImportAction =
if (result.resultCode == RESULT_OK) { registerForActivityResult(StartActivityForResult()) { result ->
binding.otpImportButton.isVisible = false if (result.resultCode == RESULT_OK) {
val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) binding.otpImportButton.isVisible = false
val contents = "${intentResult.contents}\n" val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
val contents = "${intentResult.contents}\n"
val currentExtras = binding.extraContent.text.toString()
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') binding.extraContent.append("\n$contents")
else binding.extraContent.append(contents)
snackbar(message = getString(R.string.otp_import_success))
} else {
snackbar(message = getString(R.string.otp_import_failure))
}
}
private val gpgKeySelectAction =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
lifecycleScope.launch {
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
withContext(Dispatchers.IO) { gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) }
commitChange(
getString(
R.string.git_commit_gpg_id,
getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name)
)
)
.onSuccess { encrypt(encryptionIntent) }
}
}
}
}
private fun File.findTillRoot(fileName: String, rootPath: File): File? {
val gpgFile = File(this, fileName)
if (gpgFile.exists()) return gpgFile
if (this.absolutePath == rootPath.absolutePath) {
return null
}
val parent = parentFile
return if (parent != null && parent.exists()) {
parent.findTillRoot(fileName, rootPath)
} else {
null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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() val currentExtras = binding.extraContent.text.toString()
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') binding.extraContent.append("\n$contents")
binding.extraContent.append("\n$contents") else binding.extraContent.append(contents)
else }
binding.extraContent.append(contents) }
snackbar(message = getString(R.string.otp_import_success)) 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 { } else {
snackbar(message = getString(R.string.otp_import_failure)) setBackgroundColor(getColor(android.R.color.transparent))
} }
} val path = getRelativePath(fullPath, repoPath)
// Keep empty path field visible if it is editable.
private val gpgKeySelectAction = registerForActivityResult(StartActivityForResult()) { result -> if (path.isEmpty() && !isEnabled) visibility = View.GONE
if (result.resultCode == RESULT_OK) { else {
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> directory.setText(path)
lifecycleScope.launch { oldCategory = path
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)
}
}
}
} }
} }
if (suggestedName != null) {
private fun File.findTillRoot(fileName: String, rootPath: File): File? { filename.setText(suggestedName)
val gpgFile = File(this, fileName) } else {
if (gpgFile.exists()) return gpgFile filename.requestFocus()
}
if (this.absolutePath == rootPath.absolutePath) { // Allow the user to quickly switch between storing the username as the filename or
return null // in the encrypted extras. This only makes sense if the directory structure is
} // FileBased.
if (suggestedName == null &&
val parent = parentFile AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == DirectoryStructure.FileBased
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)
} else {
filename.requestFocus()
}
// Allow the user to quickly switch between storing the username as the filename or
// in the encrypted extras. This only makes sense if the directory structure is
// FileBased.
if (suggestedName == null &&
AutofillPreferences.directoryStructure(this@PasswordCreationActivity) ==
DirectoryStructure.FileBased
) {
encryptUsername.apply {
visibility = View.VISIBLE
setOnClickListener {
if (isChecked) {
// User wants to enable username encryption, so we add it to the
// encrypted extras as the first line.
val username = filename.text.toString()
val extras = "username:$username\n${extraContent.text}"
filename.text?.clear()
extraContent.setText(extras)
} else {
// User wants to disable username encryption, so we extract the
// username from the encrypted extras and use it as the filename.
val entry = PasswordEntry("PASSWORD\n${extraContent.text}")
val username = entry.username
// username should not be null here by the logic in
// updateViewState, but it could still happen due to
// input lag.
if (username != null) {
filename.setText(username)
extraContent.setText(entry.extraContentWithoutAuthData)
}
}
}
}
listOf(filename, extraContent).forEach {
it.doOnTextChanged { _, _, _, _ -> updateViewState() }
}
}
suggestedPass?.let {
password.setText(it)
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
suggestedExtra?.let { extraContent.setText(it) }
if (shouldGeneratePassword) {
generatePassword()
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
}
updateViewState()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
setResult(RESULT_CANCELED)
onBackPressed()
}
R.id.save_password -> {
copy = false
encrypt()
}
R.id.save_and_copy_password -> {
copy = true
encrypt()
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
private fun generatePassword() {
supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { requestKey, bundle ->
if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
binding.password.setText(bundle.getString(RESULT))
}
}
when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment()
.show(supportFragmentManager, "generator")
KEY_PWGEN_TYPE_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 { encryptUsername.apply {
if (visibility != View.VISIBLE) visibility = View.VISIBLE
return@apply setOnClickListener {
val hasUsernameInFileName = filename.text.toString().isNotBlank() if (isChecked) {
val hasUsernameInExtras = entry.hasUsername() // User wants to enable username encryption, so we add it to the
isEnabled = hasUsernameInFileName xor hasUsernameInExtras // encrypted extras as the first line.
isChecked = hasUsernameInExtras 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)
}
}
}
} }
otpImportButton.isVisible = !entry.hasTotp() listOf(filename, extraContent).forEach { it.doOnTextChanged { _, _, _, _ -> updateViewState() } }
}
suggestedPass?.let {
password.setText(it)
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
suggestedExtra?.let { extraContent.setText(it) }
if (shouldGeneratePassword) {
generatePassword()
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
}
}
updateViewState()
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
setResult(RESULT_CANCELED)
onBackPressed()
}
R.id.save_password -> {
copy = false
encrypt()
}
R.id.save_and_copy_password -> {
copy = true
encrypt()
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
private fun generatePassword() {
supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { requestKey, bundle ->
if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
binding.password.setText(bundle.getString(RESULT))
}
}
when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
KEY_PWGEN_TYPE_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()
} }
/** /** Encrypts the password and the extra content */
* Encrypts the password and the extra content private fun encrypt(receivedIntent: Intent? = null) {
*/ with(binding) {
private fun encrypt(receivedIntent: Intent? = null) { val editName = filename.text.toString().trim()
with(binding) { val editPass = password.text.toString()
val editName = filename.text.toString().trim() val editExtra = extraContent.text.toString()
val editPass = password.text.toString()
val editExtra = extraContent.text.toString()
if (editName.isEmpty()) { if (editName.isEmpty()) {
snackbar(message = resources.getString(R.string.file_toast_text)) snackbar(message = resources.getString(R.string.file_toast_text))
return@with return@with
} else if (editName.contains('/')) { } else if (editName.contains('/')) {
snackbar(message = resources.getString(R.string.invalid_filename_text)) snackbar(message = resources.getString(R.string.invalid_filename_text))
return@with return@with
} }
if (editPass.isEmpty() && editExtra.isEmpty()) { if (editPass.isEmpty() && editExtra.isEmpty()) {
snackbar(message = resources.getString(R.string.empty_toast_text)) snackbar(message = resources.getString(R.string.empty_toast_text))
return@with return@with
} }
if (copy) { if (copy) {
copyPasswordToClipboard(editPass) copyPasswordToClipboard(editPass)
} }
encryptionIntent = receivedIntent ?: Intent() encryptionIntent = receivedIntent ?: Intent()
encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT
// pass enters the key ID into `.gpg-id`. // pass enters the key ID into `.gpg-id`.
val repoRoot = PasswordRepository.getRepositoryDirectory() val repoRoot = PasswordRepository.getRepositoryDirectory()
val gpgIdentifierFile = File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot) val gpgIdentifierFile =
?: File(repoRoot, ".gpg-id").apply { createNewFile() } File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot)
val gpgIdentifiers = gpgIdentifierFile.readLines() ?: File(repoRoot, ".gpg-id").apply { createNewFile() }
.filter { it.isNotBlank() } val gpgIdentifiers =
.map { line -> gpgIdentifierFile.readLines().filter { it.isNotBlank() }.map { line ->
GpgIdentifier.fromString(line) ?: run { GpgIdentifier.fromString(line)
// The line being empty means this is most likely an empty `.gpg-id` file ?: run {
// we created. Skip the validation so we can make the user add a real ID. // The line being empty means this is most likely an empty `.gpg-id`
if (line.isEmpty()) return@run // file
if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { // we created. Skip the validation so we can make the user add a real
snackbar(message = resources.getString(R.string.short_key_ids_unsupported)) // ID.
} else { if (line.isEmpty()) return@run
snackbar(message = resources.getString(R.string.invalid_gpg_id)) if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
} snackbar(message = resources.getString(R.string.short_key_ids_unsupported))
return@with } else {
} snackbar(message = resources.getString(R.string.invalid_gpg_id))
} }
if (gpgIdentifiers.isEmpty()) { return@with
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
}
val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}")
if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) {
snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}")
return
}
"${passwordDirectory.path}/$editName.gpg"
}
else -> "$fullPath/$editName.gpg"
}
lifecycleScope.launch(Dispatchers.IO) {
api?.executeApiAsync(encryptionIntent, inputStream, outputStream) { result ->
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
OpenPgpApi.RESULT_CODE_SUCCESS -> {
runCatching {
val file = File(path)
// If we're not editing, this file should not already exist!
// 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)
}
}
} }
} }
} 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)
}
companion object { encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true)
private const val KEY_PWGEN_TYPE_CLASSIC = "classic" val content = "$editPass\n$editExtra"
private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" val inputStream = ByteArrayInputStream(content.toByteArray())
const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR" val outputStream = ByteArrayOutputStream()
const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
const val RESULT = "RESULT" val path =
const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE" when {
const val RETURN_EXTRA_NAME = "NAME" // If we allowed the user to edit the relative path, we have to consider it here
const val RETURN_EXTRA_LONG_NAME = "LONG_NAME" // instead
const val RETURN_EXTRA_USERNAME = "USERNAME" // of fullPath.
const val RETURN_EXTRA_PASSWORD = "PASSWORD" directoryInputLayout.isEnabled -> {
const val EXTRA_FILE_NAME = "FILENAME" val editRelativePath = directory.text.toString().trim()
const val EXTRA_PASSWORD = "PASSWORD" if (editRelativePath.isEmpty()) {
const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT" snackbar(message = resources.getString(R.string.path_toast_text))
const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD" return
const val EXTRA_EDITING = "EDITING" }
val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}")
if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) {
snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}")
return
}
"${passwordDirectory.path}/$editName.gpg"
}
else -> "$fullPath/$editName.gpg"
}
lifecycleScope.launch(Dispatchers.IO) {
api?.executeApiAsync(encryptionIntent, inputStream, outputStream) { result ->
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
OpenPgpApi.RESULT_CODE_SUCCESS -> {
runCatching {
val file = File(path)
// If we're not editing, this file should not already exist!
// 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)
}
}
}
} }
}
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"
}
} }

View file

@ -25,138 +25,136 @@ import dev.msfjarvis.aps.util.extensions.resolveAttribute
import dev.msfjarvis.aps.util.extensions.viewBinding import dev.msfjarvis.aps.util.extensions.viewBinding
/** /**
* [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API
* API through [Builder] to create a similar UI, just at the bottom of the screen. * through [Builder] to create a similar UI, just at the bottom of the screen.
*/ */
class BasicBottomSheet private constructor( class BasicBottomSheet
val title: String?, private constructor(
val message: String, val title: String?,
val positiveButtonLabel: String?, val message: String,
val negativeButtonLabel: String?, val positiveButtonLabel: String?,
val positiveButtonClickListener: View.OnClickListener?, val negativeButtonLabel: String?,
val negativeButtonClickListener: View.OnClickListener?, val positiveButtonClickListener: View.OnClickListener?,
val negativeButtonClickListener: View.OnClickListener?,
) : BottomSheetDialogFragment() { ) : BottomSheetDialogFragment() {
private val binding by viewBinding(BasicBottomSheetBinding::bind) private val binding by viewBinding(BasicBottomSheetBinding::bind)
private var behavior: BottomSheetBehavior<FrameLayout>? = null private var behavior: BottomSheetBehavior<FrameLayout>? = null
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { private val bottomSheetCallback =
override fun onSlide(bottomSheet: View, slideOffset: Float) { 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) { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) { if (savedInstanceState != null) dismiss()
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
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { negativeButtonLabel?.let { buttonLbl -> binding.bottomSheetCancelButton.text = buttonLbl }
if (savedInstanceState != null) dismiss() binding.bottomSheetCancelButton.setOnClickListener {
return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false) negativeButtonClickListener.onClick(it)
} dismiss()
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() { fun setTitle(title: String): Builder {
super.dismiss() this.title = title
behavior?.removeBottomSheetCallback(bottomSheetCallback) return this
} }
class Builder(val context: Context) { fun setMessageRes(@StringRes messageRes: Int): Builder {
this.message = context.resources.getString(messageRes)
private var title: String? = null return this
private var message: String? = null
private var positiveButtonLabel: String? = null
private var negativeButtonLabel: String? = null
private var positiveButtonClickListener: View.OnClickListener? = null
private var negativeButtonClickListener: View.OnClickListener? = null
fun setTitleRes(@StringRes titleRes: Int): Builder {
this.title = context.resources.getString(titleRes)
return this
}
fun setTitle(title: String): Builder {
this.title = title
return this
}
fun setMessageRes(@StringRes messageRes: Int): Builder {
this.message = context.resources.getString(messageRes)
return this
}
fun setMessage(message: String): Builder {
this.message = message
return this
}
fun setPositiveButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
this.positiveButtonClickListener = listener
this.positiveButtonLabel = buttonLabel
return this
}
fun setNegativeButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
this.negativeButtonClickListener = listener
this.negativeButtonLabel = buttonLabel
return this
}
fun build(): BasicBottomSheet {
require(message != null) { "Message needs to be set" }
return BasicBottomSheet(
title,
message!!,
positiveButtonLabel,
negativeButtonLabel,
positiveButtonClickListener,
negativeButtonClickListener
)
}
} }
fun setMessage(message: String): Builder {
this.message = message
return this
}
fun setPositiveButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
this.positiveButtonClickListener = listener
this.positiveButtonLabel = buttonLabel
return this
}
fun setNegativeButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
this.negativeButtonClickListener = listener
this.negativeButtonLabel = buttonLabel
return this
}
fun build(): BasicBottomSheet {
require(message != null) { "Message needs to be set" }
return BasicBottomSheet(
title,
message!!,
positiveButtonLabel,
negativeButtonLabel,
positiveButtonClickListener,
negativeButtonClickListener
)
}
}
} }

View file

@ -30,77 +30,82 @@ import me.msfjarvis.openpgpktx.util.OpenPgpApi
class FolderCreationDialogFragment : DialogFragment() { class FolderCreationDialogFragment : DialogFragment() {
private lateinit var newFolder: File private lateinit var newFolder: File
private val keySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val keySelectAction =
if (result.resultCode == AppCompatActivity.RESULT_OK) { registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> if (result.resultCode == AppCompatActivity.RESULT_OK) {
val gpgIdentifierFile = File(newFolder, ".gpg-id") result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
gpgIdentifierFile.writeText(keyIds.joinToString("\n")) val gpgIdentifierFile = File(newFolder, ".gpg-id")
val repo = PasswordRepository.getRepository(null) gpgIdentifierFile.writeText(keyIds.joinToString("\n"))
if (repo != null) { val repo = PasswordRepository.getRepository(null)
lifecycleScope.launch { if (repo != null) {
val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath lifecycleScope.launch {
requireActivity().commitChange( val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath
getString( requireActivity()
R.string.git_commit_gpg_id, .commitChange(
BasePgpActivity.getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) getString(
), R.string.git_commit_gpg_id,
) BasePgpActivity.getLongName(
dismiss() gpgIdentifierFile.parentFile!!.absolutePath,
} repoPath,
} gpgIdentifierFile.name
)
),
)
dismiss()
} }
}
} }
}
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext()) val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
alertDialogBuilder.setTitle(R.string.title_create_folder) alertDialogBuilder.setTitle(R.string.title_create_folder)
alertDialogBuilder.setView(R.layout.folder_dialog_fragment) alertDialogBuilder.setView(R.layout.folder_dialog_fragment)
alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null) alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null)
alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> dismiss() }
dismiss() val dialog = alertDialogBuilder.create()
} dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
val dialog = alertDialogBuilder.create() dialog.setOnShowListener {
dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text) dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
dialog.setOnShowListener { createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!)
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { }
createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!)
}
}
return dialog
} }
return dialog
}
private fun createDirectory(currentDir: String) { private fun createDirectory(currentDir: String) {
val dialog = requireDialog() val dialog = requireDialog()
val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text) val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container) val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
newFolder = File("$currentDir/${folderNameView.text}") newFolder = File("$currentDir/${folderNameView.text}")
folderNameViewContainer.error = when { folderNameViewContainer.error =
newFolder.isFile -> getString(R.string.folder_creation_err_file_exists) when {
newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists) newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
else -> null newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
} else -> null
if (folderNameViewContainer.error != null) return }
newFolder.mkdirs() if (folderNameViewContainer.error != null) return
(requireActivity() as PasswordStore).refreshPasswordList(newFolder) newFolder.mkdirs()
if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) { (requireActivity() as PasswordStore).refreshPasswordList(newFolder)
keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
return keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
} else { return
dismiss() } else {
} dismiss()
} }
}
companion object { companion object {
private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY" private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY"
fun newInstance(startingDirectory: String): FolderCreationDialogFragment { fun newInstance(startingDirectory: String): FolderCreationDialogFragment {
val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory) val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory)
val fragment = FolderCreationDialogFragment() val fragment = FolderCreationDialogFragment()
fragment.arguments = extras fragment.arguments = extras
return fragment return fragment
}
} }
}
} }

View file

@ -25,53 +25,54 @@ import dev.msfjarvis.aps.util.extensions.resolveAttribute
class ItemCreationBottomSheet : BottomSheetDialogFragment() { class ItemCreationBottomSheet : BottomSheetDialogFragment() {
private var behavior: BottomSheetBehavior<FrameLayout>? = null private var behavior: BottomSheetBehavior<FrameLayout>? = null
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { private val bottomSheetCallback =
override fun onSlide(bottomSheet: View, slideOffset: Float) { 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) { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) { if (savedInstanceState != null) dismiss()
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)) }
view.background = gradientDrawable
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { override fun dismiss() {
if (savedInstanceState != null) dismiss() super.dismiss()
return inflater.inflate(R.layout.item_create_sheet, container, false) behavior?.removeBottomSheetCallback(bottomSheetCallback)
} }
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))
}
view.background = gradientDrawable
}
override fun dismiss() {
super.dismiss()
behavior?.removeBottomSheetCallback(bottomSheetCallback)
}
} }

View file

@ -20,32 +20,30 @@ import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView
class OtpImportDialogFragment : DialogFragment() { class OtpImportDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = MaterialAlertDialogBuilder(requireContext()) val builder = MaterialAlertDialogBuilder(requireContext())
val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater) val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater)
builder.setView(binding.root) builder.setView(binding.root)
builder.setPositiveButton(android.R.string.ok) { _, _ -> builder.setPositiveButton(android.R.string.ok) { _, _ ->
setFragmentResult( setFragmentResult(
PasswordCreationActivity.OTP_RESULT_REQUEST_KEY, PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
bundleOf( bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding))
PasswordCreationActivity.RESULT to getTOTPUri(binding) )
)
)
}
val dialog = builder.create()
dialog.requestInputFocusOnView<TextInputEditText>(R.id.secret)
return dialog
} }
val dialog = builder.create()
dialog.requestInputFocusOnView<TextInputEditText>(R.id.secret)
return dialog
}
private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String { private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String {
val secret = binding.secret.text.toString() val secret = binding.secret.text.toString()
val account = binding.account.text.toString() val account = binding.account.text.toString()
if (secret.isBlank()) return "" if (secret.isBlank()) return ""
val builder = Uri.Builder() val builder = Uri.Builder()
builder.scheme("otpauth") builder.scheme("otpauth")
builder.authority("totp") builder.authority("totp")
builder.appendQueryParameter("secret", secret) builder.appendQueryParameter("secret", secret)
if (account.isNotBlank()) builder.appendQueryParameter("issuer", account) if (account.isNotBlank()) builder.appendQueryParameter("issuer", account)
return builder.build().toString() return builder.build().toString()
} }
} }

View file

@ -31,72 +31,70 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class PasswordGeneratorDialogFragment : DialogFragment() { class PasswordGeneratorDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = MaterialAlertDialogBuilder(requireContext()) val builder = MaterialAlertDialogBuilder(requireContext())
val callingActivity = requireActivity() val callingActivity = requireActivity()
val binding = FragmentPwgenBinding.inflate(layoutInflater) val binding = FragmentPwgenBinding.inflate(layoutInflater)
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf") val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
val prefs = requireActivity().applicationContext val prefs = requireActivity().applicationContext.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
builder.setView(binding.root) builder.setView(binding.root)
binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false) binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false)
binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false) binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false)
binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false) binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false)
binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false) binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false)
binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false) binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false)
binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true) binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString()) binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
binding.passwordText.typeface = monoTypeface binding.passwordText.typeface = monoTypeface
return builder.run { return builder
setTitle(R.string.pwgen_title) .run {
setPositiveButton(R.string.dialog_ok) { _, _ -> setTitle(R.string.pwgen_title)
setFragmentResult( setPositiveButton(R.string.dialog_ok) { _, _ ->
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, setFragmentResult(
bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}") 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)
}
}
} }
} setNeutralButton(R.string.dialog_cancel) { _, _ -> }
setNegativeButton(R.string.pwgen_generate, null)
private fun generate(passwordField: AppCompatTextView) { create()
setPreferences() }
passwordField.text = runCatching { .apply {
generate(requireContext().applicationContext) setOnShowListener {
}.getOrElse { e -> generate(binding.passwordText)
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show() getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generate(binding.passwordText) }
""
} }
} }
}
private fun isChecked(@IdRes id: Int): Boolean { private fun generate(passwordField: AppCompatTextView) {
return requireDialog().findViewById<CheckBox>(id).isChecked setPreferences()
} passwordField.text =
runCatching { generate(requireContext().applicationContext) }.getOrElse { e ->
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
""
}
}
private fun setPreferences() { private fun isChecked(@IdRes id: Int): Boolean {
val preferences = listOfNotNull( return requireDialog().findViewById<CheckBox>(id).isChecked
PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, }
PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) },
PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, private fun setPreferences() {
PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) }, val preferences =
PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) }, listOfNotNull(
PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) } PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) },
) PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) },
val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString() PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) },
val length = lengthText.toIntOrNull()?.takeIf { it >= 0 } PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) },
?: PasswordGenerator.DEFAULT_LENGTH PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) },
setPrefs(requireActivity().applicationContext, preferences, length) 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)
}
} }

View file

@ -27,103 +27,102 @@ import dev.msfjarvis.aps.util.extensions.getString
import dev.msfjarvis.aps.util.pwgenxkpwd.CapsType import dev.msfjarvis.aps.util.pwgenxkpwd.CapsType
import dev.msfjarvis.aps.util.pwgenxkpwd.PasswordBuilder import dev.msfjarvis.aps.util.pwgenxkpwd.PasswordBuilder
/** A placeholder fragment containing a simple view. */ /** A placeholder fragment containing a simple view. */
class XkPasswordGeneratorDialogFragment : DialogFragment() { class XkPasswordGeneratorDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = MaterialAlertDialogBuilder(requireContext()) val builder = MaterialAlertDialogBuilder(requireContext())
val callingActivity = requireActivity() val callingActivity = requireActivity()
val inflater = callingActivity.layoutInflater val inflater = callingActivity.layoutInflater
val binding = FragmentXkpwgenBinding.inflate(inflater) val binding = FragmentXkpwgenBinding.inflate(inflater)
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf") val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
val prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) val prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
builder.setView(binding.root) builder.setView(binding.root)
val previousStoredCapStyle: String = runCatching { val previousStoredCapStyle: String =
prefs.getString(PREF_KEY_CAPITALS_STYLE)!! runCatching { prefs.getString(PREF_KEY_CAPITALS_STYLE)!! }.getOr(DEFAULT_CAPS_STYLE)
}.getOr(DEFAULT_CAPS_STYLE)
val lastCapitalsStyleIndex: Int = runCatching { val lastCapitalsStyleIndex: Int =
CapsType.valueOf(previousStoredCapStyle).ordinal runCatching { CapsType.valueOf(previousStoredCapStyle).ordinal }.getOr(DEFAULT_CAPS_INDEX)
}.getOr(DEFAULT_CAPS_INDEX) binding.xkCapType.setSelection(lastCapitalsStyleIndex)
binding.xkCapType.setSelection(lastCapitalsStyleIndex) binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS))
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.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.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK))
binding.xkPasswordText.typeface = monoTypeface binding.xkPasswordText.typeface = monoTypeface
builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ ->
setPreferences(binding, prefs) setPreferences(binding, prefs)
setFragmentResult( setFragmentResult(
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY, PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
bundleOf(PasswordCreationActivity.RESULT to "${binding.xkPasswordText.text}") 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
} }
private fun makeAndSetPassword(binding: FragmentXkpwgenBinding) { // flip neutral and negative buttons
PasswordBuilder(requireContext()) builder.setNeutralButton(resources.getString(R.string.dialog_cancel)) { _, _ -> }
.setNumberOfWords(Integer.valueOf(binding.xkNumWords.text.toString())) builder.setNegativeButton(resources.getString(R.string.pwgen_generate), null)
.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) { val dialog = builder.setTitle(this.resources.getString(R.string.xkpwgen_title)).create()
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 { dialog.setOnShowListener {
setPreferences(binding, prefs)
makeAndSetPassword(binding)
const val PREF_KEY_CAPITALS_STYLE = "pref_key_capitals_style" dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
const val PREF_KEY_NUM_WORDS = "pref_key_num_words" setPreferences(binding, prefs)
const val PREF_KEY_SEPARATOR = "pref_key_separator" makeAndSetPassword(binding)
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'
} }
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'
}
} }

View file

@ -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.PASSWORD_FRAGMENT_TAG
import dev.msfjarvis.aps.ui.passwords.PasswordStore import dev.msfjarvis.aps.ui.passwords.PasswordStore
class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) { class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
private lateinit var passwordList: SelectFolderFragment private lateinit var passwordList: SelectFolderFragment
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
passwordList = SelectFolderFragment() passwordList = SelectFolderFragment()
val args = Bundle() val args = Bundle()
args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) 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 { supportFragmentManager.commit { replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) }
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 { private fun selectFolder() {
menuInflater.inflate(R.menu.pgp_handler_select_folder, menu) intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
return true 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()
}
} }

View file

@ -26,56 +26,51 @@ import me.zhanghai.android.fastscroll.FastScrollerBuilder
class SelectFolderFragment : Fragment(R.layout.password_recycler_view) { class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
private val binding by viewBinding(PasswordRecyclerViewBinding::bind) private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
private lateinit var listener: OnFragmentInteractionListener private lateinit var listener: OnFragmentInteractionListener
private val model: SearchableRepositoryViewModel by activityViewModels() private val model: SearchableRepositoryViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.fab.hide() binding.fab.hide()
recyclerAdapter = PasswordItemRecyclerAdapter() recyclerAdapter = PasswordItemRecyclerAdapter().onItemClicked { _, item -> listener.onFragmentInteraction(item) }
.onItemClicked { _, item -> binding.passRecycler.apply {
listener.onFragmentInteraction(item) layoutManager = LinearLayoutManager(requireContext())
itemAnimator = null
adapter = recyclerAdapter
}
FastScrollerBuilder(binding.passRecycler).build()
registerForContextMenu(binding.passRecycler)
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
model.searchResult.observe(viewLifecycleOwner) { result -> recyclerAdapter.submitList(result.passwordItems) }
}
override fun onAttach(context: Context) {
super.onAttach(context)
runCatching {
listener =
object : OnFragmentInteractionListener {
override fun onFragmentInteraction(item: PasswordItem) {
if (item.type == PasswordItem.TYPE_CATEGORY) {
model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
} }
binding.passRecycler.apply { }
layoutManager = LinearLayoutManager(requireContext())
itemAnimator = null
adapter = recyclerAdapter
}
FastScrollerBuilder(binding.passRecycler).build()
registerForContextMenu(binding.passRecycler)
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
model.searchResult.observe(viewLifecycleOwner) { result ->
recyclerAdapter.submitList(result.passwordItems)
} }
} }
.onFailure { throw ClassCastException("$context must implement OnFragmentInteractionListener") }
}
override fun onAttach(context: Context) { val currentDir: File
super.onAttach(context) get() = model.currentDir.value!!
runCatching {
listener = object : OnFragmentInteractionListener {
override fun onFragmentInteraction(item: PasswordItem) {
if (item.type == PasswordItem.TYPE_CATEGORY) {
model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
}
}
}.onFailure {
throw ClassCastException("$context must implement OnFragmentInteractionListener")
}
}
val currentDir: File interface OnFragmentInteractionListener {
get() = model.currentDir.value!!
interface OnFragmentInteractionListener { fun onFragmentInteraction(item: PasswordItem)
}
fun onFragmentInteraction(item: PasswordItem)
}
} }

View file

@ -33,132 +33,130 @@ import net.schmizz.sshj.transport.TransportException
import net.schmizz.sshj.userauth.UserAuthException import net.schmizz.sshj.userauth.UserAuthException
/** /**
* Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related * Abstract [AppCompatActivity] that holds some information that is commonly shared across
* tasks and makes sense to be held here. * git-related tasks and makes sense to be held here.
*/ */
abstract class BaseGitActivity : ContinuationContainerActivity() { abstract class BaseGitActivity : ContinuationContainerActivity() {
/** /** Enum of possible Git operations than can be run through [launchGitOperation]. */
* Enum of possible Git operations than can be run through [launchGitOperation]. enum class GitOp {
*/ BREAK_OUT_OF_DETACHED,
enum class GitOp { CLONE,
PULL,
PUSH,
RESET,
SYNC,
}
BREAK_OUT_OF_DETACHED, /**
CLONE, * Attempt to launch the requested Git operation.
PULL, * @param operation The type of git operation to launch
PUSH, */
RESET, suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> {
SYNC, 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)
}
/** fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) {
* Attempt to launch the requested Git operation. finish()
* @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)
}
fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) { suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
finish() val error = rootCauseException(err)
} if (!isExplicitlyUserInitiatedError(error)) {
getEncryptedGitPrefs().edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) { sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
val error = rootCauseException(err) d(error)
if (!isExplicitlyUserInitiatedError(error)) { withContext(Dispatchers.Main) {
getEncryptedGitPrefs().edit { MaterialAlertDialogBuilder(this@BaseGitActivity).run {
remove(PreferenceKeys.HTTPS_PASSWORD) setTitle(resources.getString(R.string.jgit_error_dialog_title))
} setMessage(ErrorMessages[error])
sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
d(error) setOnDismissListener { onPromptDone() }
withContext(Dispatchers.Main) { show()
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()
} }
}
} else {
onPromptDone()
} }
}
/** /**
* Takes the result of [launchGitOperation] and applies any necessary transformations * Takes the result of [launchGitOperation] and applies any necessary transformations on the
* on the [throwable] returned from it * [throwable] returned from it
*/ */
private fun transformGitError(throwable: Throwable): Throwable { private fun transformGitError(throwable: Throwable): Throwable {
val err = rootCauseException(throwable) val err = rootCauseException(throwable)
return when { return when {
err.message?.contains("cannot open additional channels") == true -> { err.message?.contains("cannot open additional channels") == true -> {
GitSettings.useMultiplexing = false 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.") SSHException(
} DisconnectReason.TOO_MANY_CONNECTIONS,
err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> { "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used."
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 -> { err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> {
SSHException(DisconnectReason.HOST_KEY_NOT_VERIFIABLE, IllegalStateException(
"WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key." "Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings"
) )
} }
else -> { err is TransportException && err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> {
err 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 * Check if a given [Throwable] is the result of an error caused by the user cancelling the
* operation. * operation.
*/ */
private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean { private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean {
var cause: Throwable? = throwable var cause: Throwable? = throwable
while (cause != null) { while (cause != null) {
if (cause is SSHException && if (cause is SSHException && cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) return true
cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) cause = cause.cause
return true
cause = cause.cause
}
return false
} }
return false
}
/** /**
* Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no * Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no
* longer found. * longer found.
*/ */
private fun rootCauseException(throwable: Throwable): Throwable { private fun rootCauseException(throwable: Throwable): Throwable {
var rootCause = throwable var rootCause = throwable
// JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ exceptions. // JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ
// Also, SSHJ's UserAuthException about exhausting available authentication methods hides // exceptions.
// more useful exceptions. // Also, SSHJ's UserAuthException about exhausting available authentication methods hides
while ((rootCause is org.eclipse.jgit.errors.TransportException || // more useful exceptions.
rootCause is org.eclipse.jgit.api.errors.TransportException || while ((rootCause is org.eclipse.jgit.errors.TransportException ||
rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException || rootCause is org.eclipse.jgit.api.errors.TransportException ||
(rootCause is UserAuthException && rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException ||
rootCause.message == "Exhausted available authentication methods"))) { (rootCause is UserAuthException && rootCause.message == "Exhausted available authentication methods"))) {
rootCause = rootCause.cause ?: break rootCause = rootCause.cause ?: break
}
return rootCause
} }
return rootCause
}
} }

View file

@ -33,122 +33,113 @@ import org.eclipse.jgit.lib.RepositoryState
class GitConfigActivity : BaseGitActivity() { class GitConfigActivity : BaseGitActivity() {
private val binding by viewBinding(ActivityGitConfigBinding::inflate) private val binding by viewBinding(ActivityGitConfigBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (GitSettings.authorName.isEmpty()) if (GitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus()
binding.gitUserName.requestFocus() else binding.gitUserName.setText(GitSettings.authorName)
else binding.gitUserEmail.setText(GitSettings.authorEmail)
binding.gitUserName.setText(GitSettings.authorName) setupTools()
binding.gitUserEmail.setText(GitSettings.authorEmail) binding.saveButton.setOnClickListener {
setupTools() val email = binding.gitUserEmail.text.toString().trim()
binding.saveButton.setOnClickListener { val name = binding.gitUserName.text.toString().trim()
val email = binding.gitUserEmail.text.toString().trim() if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) {
val name = binding.gitUserName.text.toString().trim() MaterialAlertDialogBuilder(this)
if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) { .setMessage(getString(R.string.invalid_email_dialog_text))
MaterialAlertDialogBuilder(this) .setPositiveButton(getString(R.string.dialog_ok), null)
.setMessage(getString(R.string.invalid_email_dialog_text)) .show()
.setPositiveButton(getString(R.string.dialog_ok), null) } else {
.show() GitSettings.authorEmail = email
} else { GitSettings.authorName = name
GitSettings.authorEmail = email Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
GitSettings.authorName = name Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
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 { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {
onBackPressed() onBackPressed()
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
}
} }
}
/** /** Sets up the UI components of the tools section. */
* Sets up the UI components of the tools section. private fun setupTools() {
*/ val repo = PasswordRepository.getRepository(null)
private fun setupTools() { if (repo != null) {
val repo = PasswordRepository.getRepository(null) binding.gitHeadStatus.text = headStatusMsg(repo)
if (repo != null) { // enable the abort button only if we're rebasing or merging
binding.gitHeadStatus.text = headStatusMsg(repo) val needsAbort = repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
// enable the abort button only if we're rebasing or merging binding.gitAbortRebase.isEnabled = needsAbort
val needsAbort = repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
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 ->
binding.gitLog.setOnClickListener { e(ex) { "Failed to start GitLogActivity" }
runCatching { }
startActivity(Intent(this, GitLogActivity::class.java)) }
}.onFailure { ex -> binding.gitAbortRebase.setOnClickListener {
e(ex) { "Failed to start GitLogActivity" } lifecycleScope.launch {
} launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED)
} .fold(
binding.gitAbortRebase.setOnClickListener { success = {
lifecycleScope.launch { MaterialAlertDialogBuilder(this@GitConfigActivity).run {
launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED).fold( setTitle(resources.getString(R.string.git_abort_and_push_title))
success = { setMessage(
MaterialAlertDialogBuilder(this@GitConfigActivity).run { resources.getString(
setTitle(resources.getString(R.string.git_abort_and_push_title)) R.string.git_break_out_of_detached_success,
setMessage(resources.getString( GitSettings.branch,
R.string.git_break_out_of_detached_success, "conflicting-${GitSettings.branch}-...",
GitSettings.branch, )
"conflicting-${GitSettings.branch}-...",
))
setOnDismissListener { finish() }
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
show()
}
},
failure = { err ->
promptOnErrorHandler(err) {
finish()
}
},
) )
} setOnDismissListener { finish() }
} setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
binding.gitResetToRemote.setOnClickListener { show()
lifecycleScope.launch { }
launchGitOperation(GitOp.RESET).fold( },
success = ::finishOnSuccessHandler, failure = { err -> promptOnErrorHandler(err) { finish() } },
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. * Returns a user-friendly message about the current state of HEAD.
* *
* The state is recognized to be either pointing to a branch or detached. * The state is recognized to be either pointing to a branch or detached.
*/ */
private fun headStatusMsg(repo: Repository): String { private fun headStatusMsg(repo: Repository): String {
return runCatching { return runCatching {
val headRef = repo.getRef(Constants.HEAD) val headRef = repo.getRef(Constants.HEAD)
if (headRef.isSymbolic) { if (headRef.isSymbolic) {
val branchName = headRef.target.name val branchName = headRef.target.name
val shortBranchName = Repository.shortenRefName(branchName) val shortBranchName = Repository.shortenRefName(branchName)
getString(R.string.git_head_on_branch, shortBranchName) getString(R.string.git_head_on_branch, shortBranchName)
} else { } else {
val commitHash = headRef.objectId.abbreviate(8).name() val commitHash = headRef.objectId.abbreviate(8).name()
getString(R.string.git_head_detached, commitHash) getString(R.string.git_head_detached, commitHash)
} }
}.getOrElse { ex ->
e(ex) { "Error getting HEAD reference" }
getString(R.string.git_head_missing)
}
} }
.getOrElse { ex ->
e(ex) { "Error getting HEAD reference" }
getString(R.string.git_head_missing)
}
}
} }

View file

@ -41,233 +41,226 @@ import kotlinx.coroutines.withContext
*/ */
class GitServerConfigActivity : BaseGitActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val isClone = intent?.extras?.getBoolean("cloning") ?: false val isClone = intent?.extras?.getBoolean("cloning") ?: false
if (isClone) { if (isClone) {
binding.saveButton.text = getString(R.string.clone_button) binding.saveButton.text = getString(R.string.clone_button)
} }
setContentView(binding.root) setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
newAuthMode = GitSettings.authMode newAuthMode = GitSettings.authMode
binding.authModeGroup.apply { binding.authModeGroup.apply {
when (newAuthMode) { when (newAuthMode) {
AuthMode.SshKey -> check(binding.authModeSshKey.id) AuthMode.SshKey -> check(binding.authModeSshKey.id)
AuthMode.Password -> check(binding.authModePassword.id) AuthMode.Password -> check(binding.authModePassword.id)
AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id) AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id)
AuthMode.None -> check(View.NO_ID) AuthMode.None -> check(View.NO_ID)
} }
setOnCheckedChangeListener { _, checkedId -> setOnCheckedChangeListener { _, checkedId ->
when (checkedId) { when (checkedId) {
binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
binding.authModePassword.id -> newAuthMode = AuthMode.Password binding.authModePassword.id -> newAuthMode = AuthMode.Password
View.NO_ID -> newAuthMode = AuthMode.None View.NO_ID -> newAuthMode = AuthMode.None
}
}
}
binding.serverUrl.setText(GitSettings.url.also {
if (it.isNullOrEmpty()) return@also
setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
})
binding.serverBranch.setText(GitSettings.branch)
binding.serverUrl.doOnTextChanged { text, _, _, _ ->
if (text.isNullOrEmpty()) return@doOnTextChanged
setAuthModes(text.startsWith("http://") || text.startsWith("https://"))
}
binding.clearHostKeyButton.isVisible = GitSettings.hasSavedHostKey()
binding.clearHostKeyButton.setOnClickListener {
GitSettings.clearSavedHostKey()
Snackbar.make(binding.root, getString(R.string.clear_saved_host_key_success), Snackbar.LENGTH_LONG).show()
it.isVisible = false
}
binding.saveButton.setOnClickListener {
val newUrl = binding.serverUrl.text.toString().trim()
// If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://`
// in the beginning will cause the port to be seen as part of the path. Let users know
// about it and offer a quickfix.
if (newUrl.contains(PORT_REGEX)) {
if (newUrl.startsWith("https://")) {
BasicBottomSheet.Builder(this)
.setTitleRes(R.string.https_scheme_with_port_title)
.setMessageRes(R.string.https_scheme_with_port_message)
.setPositiveButtonClickListener {
binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/"))
}
.build()
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
return@setOnClickListener
} else if (!newUrl.startsWith("ssh://")) {
BasicBottomSheet.Builder(this)
.setTitleRes(R.string.ssh_scheme_needed_title)
.setMessageRes(R.string.ssh_scheme_needed_message)
.setPositiveButtonClickListener {
@Suppress("SetTextI18n")
binding.serverUrl.setText("ssh://$newUrl")
}
.build()
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
return@setOnClickListener
}
}
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 { binding.serverUrl.setText(
return when (item.itemId) { GitSettings.url.also {
android.R.id.home -> { if (it.isNullOrEmpty()) return@also
onBackPressed() setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
true }
} )
else -> super.onOptionsItemSelected(item) binding.serverBranch.setText(GitSettings.branch)
}
binding.serverUrl.doOnTextChanged { text, _, _, _ ->
if (text.isNullOrEmpty()) return@doOnTextChanged
setAuthModes(text.startsWith("http://") || text.startsWith("https://"))
} }
private fun setAuthModes(isHttps: Boolean) = with(binding) { binding.clearHostKeyButton.isVisible = GitSettings.hasSavedHostKey()
if (isHttps) { binding.clearHostKeyButton.setOnClickListener {
authModeSshKey.isVisible = false GitSettings.clearSavedHostKey()
authModeOpenKeychain.isVisible = false Snackbar.make(binding.root, getString(R.string.clear_saved_host_key_success), Snackbar.LENGTH_LONG).show()
authModePassword.isVisible = true it.isVisible = false
if (authModeGroup.checkedChipId != authModePassword.id) }
authModeGroup.check(View.NO_ID) binding.saveButton.setOnClickListener {
} else { val newUrl = binding.serverUrl.text.toString().trim()
authModeSshKey.isVisible = true // If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://`
authModeOpenKeychain.isVisible = true // in the beginning will cause the port to be seen as part of the path. Let users know
authModePassword.isVisible = true // about it and offer a quickfix.
if (authModeGroup.checkedChipId == View.NO_ID) if (newUrl.contains(PORT_REGEX)) {
authModeGroup.check(authModeSshKey.id) if (newUrl.startsWith("https://")) {
BasicBottomSheet.Builder(this)
.setTitleRes(R.string.https_scheme_with_port_title)
.setMessageRes(R.string.https_scheme_with_port_message)
.setPositiveButtonClickListener { binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) }
.build()
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
return@setOnClickListener
} else if (!newUrl.startsWith("ssh://")) {
BasicBottomSheet.Builder(this)
.setTitleRes(R.string.ssh_scheme_needed_title)
.setMessageRes(R.string.ssh_scheme_needed_message)
.setPositiveButtonClickListener { @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl") }
.build()
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
return@setOnClickListener
} }
}
when (val updateResult =
GitSettings.updateConnectionSettingsIfValid(
newAuthMode = newAuthMode,
newUrl = binding.serverUrl.text.toString().trim(),
newBranch = binding.serverBranch.text.toString().trim()
)
) {
GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> {
Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show()
}
is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> {
when (updateResult.newProtocol) {
Protocol.Https ->
BasicBottomSheet.Builder(this)
.setTitleRes(R.string.ssh_scheme_needed_title)
.setMessageRes(R.string.git_server_config_save_missing_username_https)
.setPositiveButtonClickListener {}
.build()
.show(supportFragmentManager, "HTTPS_MISSING_USERNAME")
Protocol.Ssh ->
BasicBottomSheet.Builder(this)
.setTitleRes(R.string.ssh_scheme_needed_title)
.setMessageRes(R.string.git_server_config_save_missing_username_ssh)
.setPositiveButtonClickListener {}
.build()
.show(supportFragmentManager, "SSH_MISSING_USERNAME")
}
}
GitSettings.UpdateConnectionSettingsResult.Valid -> {
if (isClone && PasswordRepository.getRepository(null) == null) PasswordRepository.initialize()
if (!isClone) {
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT)
.show()
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
} else {
cloneRepository()
}
}
is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> {
val message =
getString(
R.string.git_server_config_save_auth_mode_mismatch,
updateResult.newProtocol,
updateResult.validModes.joinToString(", "),
)
Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
}
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
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)
}
} }
/** /** Clones the repository, the directory exists, deletes it */
* Clones the repository, the directory exists, deletes it private fun cloneRepository() {
*/ val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory())
private fun cloneRepository() { val localDirFiles = localDir.listFiles() ?: emptyArray()
val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory()) // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
val localDirFiles = localDir.listFiles() ?: emptyArray() if (localDir.exists() && localDirFiles.isNotEmpty() && !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")
// Warn if non-empty folder unless it's a just-initialized store that has just a .git folder ) {
if (localDir.exists() && localDirFiles.isNotEmpty() && MaterialAlertDialogBuilder(this)
!(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) { .setTitle(R.string.dialog_delete_title)
MaterialAlertDialogBuilder(this) .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
.setTitle(R.string.dialog_delete_title) .setCancelable(false)
.setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString())) .setPositiveButton(R.string.dialog_delete) { dialog, _ ->
.setCancelable(false) runCatching {
.setPositiveButton(R.string.dialog_delete) { dialog, _ ->
runCatching {
lifecycleScope.launch {
val snackbar = snackbar(message = getString(R.string.delete_directory_progress_text), length = Snackbar.LENGTH_INDEFINITE)
withContext(Dispatchers.IO) {
localDir.deleteRecursively()
}
snackbar.dismiss()
launchGitOperation(GitOp.CLONE).fold(
success = {
setResult(RESULT_OK)
finish()
},
failure = { err ->
promptOnErrorHandler(err) {
finish()
}
}
)
}
}.onFailure { e ->
e.printStackTrace()
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
}
dialog.cancel()
}
.setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ ->
dialog.cancel()
}
.show()
} else {
runCatching {
// Silently delete & replace the lone .git folder if it exists
if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
localDir.deleteRecursively()
}
}.onFailure { e ->
e(e)
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
}
lifecycleScope.launch { lifecycleScope.launch {
launchGitOperation(GitOp.CLONE).fold( val snackbar =
success = { snackbar(
setResult(RESULT_OK) message = getString(R.string.delete_directory_progress_text),
finish() length = Snackbar.LENGTH_INDEFINITE
}, )
failure = { promptOnErrorHandler(it) }, 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()
companion object { MaterialAlertDialogBuilder(this).setMessage(e.message).show()
private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
fun createCloneIntent(context: Context): Intent {
return Intent(context, GitServerConfigActivity::class.java).apply {
putExtra("cloning", true)
} }
dialog.cancel()
} }
.setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() }
.show()
} else {
runCatching {
// Silently delete & replace the lone .git folder if it exists
if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
localDir.deleteRecursively()
}
}
.onFailure { e ->
e(e)
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
}
lifecycleScope.launch {
launchGitOperation(GitOp.CLONE)
.fold(
success = {
setResult(RESULT_OK)
finish()
},
failure = { promptOnErrorHandler(it) },
)
}
} }
}
companion object {
private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
fun createCloneIntent(context: Context): Intent {
return Intent(context, GitServerConfigActivity::class.java).apply { putExtra("cloning", true) }
}
}
} }

View file

@ -20,30 +20,30 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
*/ */
class GitLogActivity : BaseGitActivity() { class GitLogActivity : BaseGitActivity() {
private val binding by viewBinding(ActivityGitLogBinding::inflate) private val binding by viewBinding(ActivityGitLogBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
createRecyclerView() createRecyclerView()
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {
finish() finish()
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
}
} }
}
private fun createRecyclerView() { private fun createRecyclerView() {
binding.gitLogRecyclerView.apply { binding.gitLogRecyclerView.apply {
setHasFixedSize(true) setHasFixedSize(true)
addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL)) addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
adapter = GitLogAdapter() adapter = GitLogAdapter()
}
} }
}
} }

View file

@ -16,43 +16,42 @@ import java.text.DateFormat
import java.util.Date import java.util.Date
private fun shortHash(hash: String): String { private fun shortHash(hash: String): String {
return hash.substring(0 until 8) return hash.substring(0 until 8)
} }
private fun stringFrom(date: Date): String { 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>() { class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
private val model = GitLogModel() private val model = GitLogModel()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false) val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false)
return ViewHolder(binding) 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) { override fun getItemCount() = model.size
val commit = model.get(position)
if (commit == null) {
e { "There is no git commit for view holder at position $position." }
return
}
viewHolder.bind(commit)
}
override fun getItemCount() = model.size class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(commit: GitCommit) =
with(binding) {
fun bind(commit: GitCommit) = with(binding) { gitLogRowMessage.text = commit.shortMessage
gitLogRowMessage.text = commit.shortMessage gitLogRowHash.text = shortHash(commit.hash)
gitLogRowHash.text = shortHash(commit.hash) gitLogRowTime.text = stringFrom(commit.time)
gitLogRowTime.text = stringFrom(commit.time) }
} }
}
} }

View file

@ -18,46 +18,46 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class LaunchActivity : AppCompatActivity() { class LaunchActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val prefs = sharedPrefs val prefs = sharedPrefs
if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) { if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) {
BiometricAuthenticator.authenticate(this) { BiometricAuthenticator.authenticate(this) {
when (it) { when (it) {
is BiometricAuthenticator.Result.Success -> { is BiometricAuthenticator.Result.Success -> {
startTargetActivity(false) startTargetActivity(false)
} }
is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> { is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) } prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) }
startTargetActivity(false) startTargetActivity(false)
} }
is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> { is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> {
finish() finish()
} }
}
}
} else {
startTargetActivity(true)
} }
}
} else {
startTargetActivity(true)
} }
}
private fun startTargetActivity(noAuth: Boolean) { private fun startTargetActivity(noAuth: Boolean) {
val intentToStart = if (intent.action == ACTION_DECRYPT_PASS) val intentToStart =
Intent(this, DecryptActivity::class.java).apply { if (intent.action == ACTION_DECRYPT_PASS)
putExtra("NAME", intent.getStringExtra("NAME")) Intent(this, DecryptActivity::class.java).apply {
putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH")) putExtra("NAME", intent.getStringExtra("NAME"))
putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH")) putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH"))
putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L)) putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH"))
} putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L))
else }
Intent(this, PasswordStore::class.java) else Intent(this, PasswordStore::class.java)
startActivity(intentToStart) 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"
} }
} }

View file

@ -11,16 +11,16 @@ import dev.msfjarvis.aps.R
class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) { class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
supportActionBar?.hide() supportActionBar?.hide()
} }
override fun onBackPressed() { override fun onBackPressed() {
if (supportFragmentManager.backStackEntryCount == 0) { if (supportFragmentManager.backStackEntryCount == 0) {
finishAffinity() finishAffinity()
} else { } else {
super.onBackPressed() super.onBackPressed()
}
} }
}
} }

View file

@ -22,37 +22,34 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class CloneFragment : Fragment(R.layout.fragment_clone) { 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 -> private val cloneAction =
if (result.resultCode == AppCompatActivity.RESULT_OK) { registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } if (result.resultCode == AppCompatActivity.RESULT_OK) {
finish() settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
} finish()
}
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.cloneRemote.setOnClickListener { binding.cloneRemote.setOnClickListener { cloneToHiddenDir() }
cloneToHiddenDir() binding.createLocal.setOnClickListener {
} parentFragmentManager.performTransactionWithBackStack(RepoLocationFragment.newInstance())
binding.createLocal.setOnClickListener {
parentFragmentManager.performTransactionWithBackStack(RepoLocationFragment.newInstance())
}
} }
}
/** /** Clones a remote Git repository to the app's private directory */
* Clones a remote Git repository to the app's private directory private fun cloneToHiddenDir() {
*/ settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) }
private fun cloneToHiddenDir() { cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) } }
cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
}
companion object { companion object {
fun newInstance(): CloneFragment = CloneFragment() fun newInstance(): CloneFragment = CloneFragment()
} }
} }

View file

@ -30,37 +30,37 @@ import me.msfjarvis.openpgpktx.util.OpenPgpApi
class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) { class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
private val binding by viewBinding(FragmentKeySelectionBinding::bind) private val binding by viewBinding(FragmentKeySelectionBinding::bind)
private val gpgKeySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val gpgKeySelectAction =
if (result.resultCode == AppCompatActivity.RESULT_OK) { registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> if (result.resultCode == AppCompatActivity.RESULT_OK) {
lifecycleScope.launch { result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
withContext(Dispatchers.IO) { lifecycleScope.launch {
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") withContext(Dispatchers.IO) {
gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
} gpgIdentifierFile.writeText((keyIds + "").joinToString("\n"))
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
requireActivity().commitChange(getString(
R.string.git_commit_gpg_id,
getString(R.string.app_name)
))
}
} }
} else { settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
throw IllegalStateException("Failed to initialize repository state.") 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.selectKey.setOnClickListener { gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) } binding.selectKey.setOnClickListener {
gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
} }
}
companion object { companion object {
fun newInstance() = KeySelectionFragment() fun newInstance() = KeySelectionFragment()
} }
} }

View file

@ -35,159 +35,155 @@ import java.io.File
class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) { class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
private val directorySelectIntent by lazy(LazyThreadSafetyMode.NONE) { Intent(requireContext(), DirectorySelectionActivity::class.java) } private val directorySelectIntent by lazy(LazyThreadSafetyMode.NONE) {
private val binding by viewBinding(FragmentRepoLocationBinding::bind) Intent(requireContext(), DirectorySelectionActivity::class.java)
private val sortOrder: PasswordSortOrder }
get() = PasswordSortOrder.getSortOrder(settings) private val binding by viewBinding(FragmentRepoLocationBinding::bind)
private val sortOrder: PasswordSortOrder
get() = PasswordSortOrder.getSortOrder(settings)
private val repositoryInitAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val repositoryInitAction =
if (result.resultCode == AppCompatActivity.RESULT_OK) { registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
initializeRepositoryInfo() if (result.resultCode == AppCompatActivity.RESULT_OK) {
}
}
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 repositoryUsePermGrantedAction = createPermGrantedAction {
initializeRepositoryInfo() initializeRepositoryInfo()
}
} }
private val repositoryChangePermGrantedAction = createPermGrantedAction { private val externalDirectorySelectAction =
repositoryInitAction.launch(directorySelectIntent) registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
} if (result.resultCode == AppCompatActivity.RESULT_OK) {
if (checkExternalDirectory()) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { finish()
super.onViewCreated(view, savedInstanceState)
binding.hidden.setOnClickListener {
createRepoInHiddenDir()
}
binding.sdcard.setOnClickListener {
createRepoFromExternalDir()
}
}
/**
* Initializes an empty repository in the app's private directory
*/
private fun createRepoInHiddenDir() {
settings.edit {
putBoolean(PreferenceKeys.GIT_EXTERNAL, false)
remove(PreferenceKeys.GIT_EXTERNAL_REPO)
}
initializeRepositoryInfo()
}
/**
* Initializes an empty repository in a selected directory if one does not already exist
*/
private fun createRepoFromExternalDir() {
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) }
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
if (externalRepo == null) {
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
externalDirPermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
// Unlikely we have storage permissions without user ever selecting a directory,
// but let's not assume.
externalDirectorySelectAction.launch(directorySelectIntent)
}
} else { } else {
MaterialAlertDialogBuilder(requireActivity()) createRepository()
.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()
} }
}
} }
private fun checkExternalDirectory(): Boolean { private val externalDirPermGrantedAction = createPermGrantedAction {
if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) && externalDirectorySelectAction.launch(directorySelectIntent)
settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null) { }
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
val dir = externalRepoPath?.let { File(it) } private val repositoryUsePermGrantedAction = createPermGrantedAction { initializeRepositoryInfo() }
if (dir != null && // The directory could be opened
dir.exists() && // The directory exists private val repositoryChangePermGrantedAction = createPermGrantedAction {
dir.isDirectory && // The directory, is really a directory repositoryInitAction.launch(directorySelectIntent)
dir.listFilesRecursively().isNotEmpty() && // The directory contains files }
// The directory contains a non-zero number of password files
PasswordRepository.getPasswords(dir, PasswordRepository.getRepositoryDirectory(), sortOrder).isNotEmpty() override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
) { super.onViewCreated(view, savedInstanceState)
PasswordRepository.closeRepository() binding.hidden.setOnClickListener { createRepoInHiddenDir() }
return true
} binding.sdcard.setOnClickListener { createRepoFromExternalDir() }
}
/** Initializes an empty repository in the app's private directory */
private fun createRepoInHiddenDir() {
settings.edit {
putBoolean(PreferenceKeys.GIT_EXTERNAL, false)
remove(PreferenceKeys.GIT_EXTERNAL_REPO)
}
initializeRepositoryInfo()
}
/** Initializes an empty repository in a selected directory if one does not already exist */
private fun createRepoFromExternalDir() {
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) }
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
if (externalRepo == null) {
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
externalDirPermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
// Unlikely we have storage permissions without user ever selecting a directory,
// but let's not assume.
externalDirectorySelectAction.launch(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()
}
} }
return false .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()
}
}
private fun checkExternalDirectory(): Boolean {
if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) &&
settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null
) {
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
val dir = externalRepoPath?.let { File(it) }
if (dir != null && // The directory could be opened
dir.exists() && // The directory exists
dir.isDirectory && // The directory, is really a directory
dir.listFilesRecursively().isNotEmpty() && // The directory contains files
// The directory contains a non-zero number of password files
PasswordRepository.getPasswords(dir, PasswordRepository.getRepositoryDirectory(), sortOrder).isNotEmpty()
) {
PasswordRepository.closeRepository()
return true
}
}
return false
}
private fun createRepository() {
val localDir = PasswordRepository.getRepositoryDirectory()
runCatching {
check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" }
PasswordRepository.createRepository(localDir)
if (!PasswordRepository.isInitialized) {
PasswordRepository.initialize()
}
parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance())
}
.onFailure { e ->
e(e)
if (!localDir.delete()) {
d { "Failed to delete local repository: $localDir" }
}
finish()
}
}
private fun initializeRepositoryInfo() {
val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
if (externalRepo && !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
return
}
if (externalRepo && externalRepoPath != null) {
if (checkExternalDirectory()) {
finish()
return
}
}
createRepository()
}
private fun createPermGrantedAction(block: () -> Unit) =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
block.invoke()
}
} }
private fun createRepository() { companion object {
val localDir = PasswordRepository.getRepositoryDirectory()
runCatching {
check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" }
PasswordRepository.createRepository(localDir)
if (!PasswordRepository.isInitialized) {
PasswordRepository.initialize()
}
parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance())
}.onFailure { e ->
e(e)
if (!localDir.delete()) {
d { "Failed to delete local repository: $localDir" }
}
finish()
}
}
private fun initializeRepositoryInfo() { fun newInstance(): RepoLocationFragment = RepoLocationFragment()
val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) }
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
if (externalRepo && !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
return
}
if (externalRepo && externalRepoPath != null) {
if (checkExternalDirectory()) {
finish()
return
}
}
createRepository()
}
private fun createPermGrantedAction(block: () -> Unit) =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) {
block.invoke()
}
}
companion object {
fun newInstance(): RepoLocationFragment = RepoLocationFragment()
}
} }

View file

@ -20,11 +20,13 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
@Suppress("unused") @Suppress("unused")
class WelcomeFragment : Fragment(R.layout.fragment_welcome) { 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.letsGo.setOnClickListener { parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) } binding.letsGo.setOnClickListener {
binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), SettingsActivity::class.java)) } parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance())
} }
binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), SettingsActivity::class.java)) }
}
} }

View file

@ -49,296 +49,278 @@ import me.zhanghai.android.fastscroll.FastScrollerBuilder
class PasswordFragment : Fragment(R.layout.password_recycler_view) { class PasswordFragment : Fragment(R.layout.password_recycler_view) {
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
private lateinit var listener: OnFragmentInteractionListener private lateinit var listener: OnFragmentInteractionListener
private lateinit var settings: SharedPreferences private lateinit var settings: SharedPreferences
private var recyclerViewStateToRestore: Parcelable? = null private var recyclerViewStateToRestore: Parcelable? = null
private var actionMode: ActionMode? = null private var actionMode: ActionMode? = null
private var scrollTarget: File? = null private var scrollTarget: File? = null
private val model: SearchableRepositoryViewModel by activityViewModels() private val model: SearchableRepositoryViewModel by activityViewModels()
private val binding by viewBinding(PasswordRecyclerViewBinding::bind) private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
private val swipeResult = registerForActivityResult(StartActivityForResult()) { private val swipeResult =
binding.swipeRefresher.isRefreshing = false registerForActivityResult(StartActivityForResult()) {
binding.swipeRefresher.isRefreshing = false
requireStore().refreshPasswordList()
}
val currentDir: File
get() = model.currentDir.value!!
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
settings = requireContext().sharedPrefs
initializePasswordList()
binding.fab.setOnClickListener { ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") }
childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
when (bundle.getString(ACTION_KEY)) {
ACTION_FOLDER -> requireStore().createFolder()
ACTION_PASSWORD -> requireStore().createPassword()
}
}
}
private fun initializePasswordList() {
val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
binding.swipeRefresher.setOnRefreshListener {
if (!hasGitDir) {
requireStore().refreshPasswordList() 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 recyclerAdapter =
get() = model.currentDir.value!! 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
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { if (actionMode == null)
super.onViewCreated(view, savedInstanceState) actionMode = requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged
settings = requireContext().sharedPrefs
initializePasswordList() if (!selection.isEmpty) {
binding.fab.setOnClickListener { actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size())
ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") actionMode!!.invalidate()
} } else {
childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle -> actionMode!!.finish()
when (bundle.getString(ACTION_KEY)) { }
ACTION_FOLDER -> requireStore().createFolder()
ACTION_PASSWORD -> requireStore().createPassword()
}
} }
val recyclerView = binding.passRecycler
recyclerView.apply {
addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
layoutManager = LinearLayoutManager(requireContext())
itemAnimator = OnOffItemAnimator()
adapter = recyclerAdapter
} }
private fun initializePasswordList() { FastScrollerBuilder(recyclerView).build()
val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git") recyclerAdapter.makeSelectable(recyclerView)
val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true) registerForContextMenu(recyclerView)
binding.swipeRefresher.setOnRefreshListener {
if (!hasGitDir) { val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
requireStore().refreshPasswordList() model.navigateTo(File(path), pushPreviousLocation = false)
binding.swipeRefresher.isRefreshing = false model.searchResult.observe(viewLifecycleOwner) { result ->
} else if (!PasswordRepository.isGitRepo()) { // Only run animations when the new list is filtered, i.e., the user submitted a search,
BasicBottomSheet.Builder(requireContext()) // and not on folder navigations since the latter leads to too many removal animations.
.setMessageRes(R.string.clone_git_repo) (recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
.setPositiveButtonClickListener(getString(R.string.clone_button)) { recyclerAdapter.submitList(result.passwordItems) {
swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext())) when {
} result.isFiltered -> {
.build() // When the result is filtered, we always scroll to the top since that is
.show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO") // where
binding.swipeRefresher.isRefreshing = false // the best fuzzy match appears.
} else { recyclerView.scrollToPosition(0)
// When authentication is set to AuthMode.None then the only git operation we can }
// run is a pull, so automatically fallback to that. scrollTarget != null -> {
val operationId = when (GitSettings.authMode) { scrollTarget?.let { recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) }
AuthMode.None -> BaseGitActivity.GitOp.PULL scrollTarget = null
else -> BaseGitActivity.GitOp.SYNC }
} else -> {
requireStore().apply { // When the result is not filtered and there is a saved scroll position for
lifecycleScope.launch { // it,
launchGitOperation(operationId).fold( // we try to restore it.
success = { recyclerViewStateToRestore?.let { recyclerView.layoutManager!!.onRestoreInstanceState(it) }
binding.swipeRefresher.isRefreshing = false recyclerViewStateToRestore = null
refreshPasswordList() }
},
failure = { err ->
promptOnErrorHandler(err) {
binding.swipeRefresher.isRefreshing = false
}
},
)
}
}
}
}
recyclerAdapter = PasswordItemRecyclerAdapter()
.onItemClicked { _, item ->
listener.onFragmentInteraction(item)
}
.onSelectionChanged { selection ->
// In order to not interfere with drag selection, we disable the SwipeRefreshLayout
// once an item is selected.
binding.swipeRefresher.isEnabled = selection.isEmpty
if (actionMode == null)
actionMode = requireStore().startSupportActionMode(actionModeCallback)
?: return@onSelectionChanged
if (!selection.isEmpty) {
actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size())
actionMode!!.invalidate()
} else {
actionMode!!.finish()
}
}
val recyclerView = binding.passRecycler
recyclerView.apply {
addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
layoutManager = LinearLayoutManager(requireContext())
itemAnimator = OnOffItemAnimator()
adapter = recyclerAdapter
}
FastScrollerBuilder(recyclerView).build()
recyclerAdapter.makeSelectable(recyclerView)
registerForContextMenu(recyclerView)
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
model.navigateTo(File(path), pushPreviousLocation = false)
model.searchResult.observe(viewLifecycleOwner) { result ->
// Only run animations when the new list is filtered, i.e., the user submitted a search,
// and not on folder navigations since the latter leads to too many removal animations.
(recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
recyclerAdapter.submitList(result.passwordItems) {
when {
result.isFiltered -> {
// When the result is filtered, we always scroll to the top since that is where
// the best fuzzy match appears.
recyclerView.scrollToPosition(0)
}
scrollTarget != null -> {
scrollTarget?.let {
recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it))
}
scrollTarget = null
}
else -> {
// When the result is not filtered and there is a saved scroll position for it,
// we try to restore it.
recyclerViewStateToRestore?.let {
recyclerView.layoutManager!!.onRestoreInstanceState(it)
}
recyclerViewStateToRestore = null
}
}
}
} }
}
} }
}
private val actionModeCallback = object : ActionMode.Callback { private val actionModeCallback =
// Called when the action mode is created; startActionMode() was called object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { // Called when the action mode is created; startActionMode() was called
// Inflate a menu resource providing context menu items override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.context_pass, menu) // Inflate a menu resource providing context menu items
// hide the fab mode.menuInflater.inflate(R.menu.context_pass, menu)
animateFab(false) // hide the fab
return true animateFab(false)
}
// Called each time the action mode is shown. Always called after onCreateActionMode, but
// may be called multiple times if the mode is invalidated.
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.menu_edit_password).isVisible =
recyclerAdapter.getSelectedItems()
.all { it.type == PasswordItem.TYPE_CATEGORY }
return true
}
// Called when the user selects a contextual menu item
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
return when (item.itemId) {
R.id.menu_delete_password -> {
requireStore().deletePasswords(recyclerAdapter.getSelectedItems())
// Action picked, so close the CAB
mode.finish()
true
}
R.id.menu_move_password -> {
requireStore().movePasswords(recyclerAdapter.getSelectedItems())
false
}
R.id.menu_edit_password -> {
requireStore().renameCategory(recyclerAdapter.getSelectedItems())
mode.finish()
false
}
else -> false
}
}
// Called when the user exits the action mode
override fun onDestroyActionMode(mode: ActionMode) {
recyclerAdapter.requireSelectionTracker().clearSelection()
actionMode = null
// show the fab
animateFab(true)
}
private fun animateFab(show: Boolean) = with(binding.fab) {
val animation = AnimationUtils.loadAnimation(
context, if (show) R.anim.scale_up else R.anim.scale_down
)
animation.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationRepeat(animation: Animation?) {
}
override fun onAnimationEnd(animation: Animation?) {
if (!show) visibility = View.GONE
}
override fun onAnimationStart(animation: Animation?) {
if (show) visibility = View.VISIBLE
}
})
animate().rotationBy(if (show) -90f else 90f)
.setStartDelay(if (show) 100 else 0)
.setDuration(100)
.start()
startAnimation(animation)
}
}
override fun onAttach(context: Context) {
super.onAttach(context)
runCatching {
listener = object : OnFragmentInteractionListener {
override fun onFragmentInteraction(item: PasswordItem) {
if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) {
//save the time when password was used
val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
preferences.edit {
putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString())
}
}
if (item.type == PasswordItem.TYPE_CATEGORY) {
navigateTo(item.file)
} else {
if (requireArguments().getBoolean("matchWith", false)) {
requireStore().matchPasswordWithApp(item)
} else {
requireStore().decryptPassword(item)
}
}
}
}
}.onFailure {
throw ClassCastException("$context must implement OnFragmentInteractionListener")
}
}
private fun requireStore() = requireActivity() as PasswordStore
/**
* Returns true if the back press was handled by the [Fragment].
*/
fun onBackPressedInActivity(): Boolean {
if (!model.canNavigateBack)
return false
// The RecyclerView state is restored when the asynchronous update operation on the
// adapter is completed.
recyclerViewStateToRestore = model.navigateBack()
if (!model.canNavigateBack)
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
return true return true
} }
fun dismissActionMode() { // Called each time the action mode is shown. Always called after onCreateActionMode,
actionMode?.finish() // 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
}
companion object { // 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
}
}
const val ITEM_CREATION_REQUEST_KEY = "creation_key" // Called when the user exits the action mode
const val ACTION_KEY = "action" override fun onDestroyActionMode(mode: ActionMode) {
const val ACTION_FOLDER = "folder" recyclerAdapter.requireSelectionTracker().clearSelection()
const val ACTION_PASSWORD = "password" actionMode = null
// show the fab
animateFab(true)
}
fun newInstance(args: Bundle): PasswordFragment { private fun animateFab(show: Boolean) =
val fragment = PasswordFragment() with(binding.fab) {
fragment.arguments = args val animation = AnimationUtils.loadAnimation(context, if (show) R.anim.scale_up else R.anim.scale_down)
return fragment 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()) }
}
fun navigateTo(file: File) { if (item.type == PasswordItem.TYPE_CATEGORY) {
requireStore().clearSearch() navigateTo(item.file)
model.navigateTo( } else {
file, if (requireArguments().getBoolean("matchWith", false)) {
recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState() requireStore().matchPasswordWithApp(item)
) } else {
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true) requireStore().decryptPassword(item)
}
}
}
}
} }
.onFailure { throw ClassCastException("$context must implement OnFragmentInteractionListener") }
}
fun scrollToOnNextRefresh(file: File) { private fun requireStore() = requireActivity() as PasswordStore
scrollTarget = file
/** 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
} }
}
interface OnFragmentInteractionListener { fun navigateTo(file: File) {
requireStore().clearSearch()
model.navigateTo(file, recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState())
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
fun onFragmentInteraction(item: PasswordItem) fun scrollToOnNextRefresh(file: File) {
} scrollTarget = file
}
interface OnFragmentInteractionListener {
fun onFragmentInteraction(item: PasswordItem)
}
} }

View file

@ -27,49 +27,39 @@ private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex()
class ProxySelectorActivity : AppCompatActivity() { class ProxySelectorActivity : AppCompatActivity() {
private val binding by viewBinding(ActivityProxySelectorBinding::inflate) private val binding by viewBinding(ActivityProxySelectorBinding::inflate)
private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) { applicationContext.getEncryptedProxyPrefs() } private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) { applicationContext.getEncryptedProxyPrefs() }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
with(binding) { with(binding) {
proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST)) proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST))
proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME)) proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME))
proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let { proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let { proxyPort.setText("$it") }
proxyPort.setText("$it") proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD))
} save.setOnClickListener { saveSettings() }
proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD)) proxyHost.doOnTextChanged { text, _, _, _ ->
save.setOnClickListener { saveSettings() } if (text != null) {
proxyHost.doOnTextChanged { text, _, _, _ -> proxyHost.error =
if (text != null) { if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) {
proxyHost.error = if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) { null
null } else {
} else { getString(R.string.invalid_proxy_url)
getString(R.string.invalid_proxy_url)
}
}
} }
} }
}
} }
}
private fun saveSettings() { private fun saveSettings() {
proxyPrefs.edit { proxyPrefs.edit {
binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let { binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyHost = it }
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.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let { binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyPassword = it }
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() }
} }
ProxyUtils.setDefaultProxy()
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
}
} }

View file

@ -33,94 +33,94 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider { class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider {
private val isAutofillServiceEnabled: Boolean private val isAutofillServiceEnabled: Boolean
get() { get() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return activity.autofillManager?.hasEnabledAutofillServices() == true 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 -> {
}
}
}
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)
}
setNegativeButton(R.string.dialog_cancel, null)
setOnDismissListener { pref.checked = isAutofillServiceEnabled }
activity.lifecycle.addObserver(observer)
show()
}
} }
override fun provideSettings(builder: PreferenceScreen.Builder) { @RequiresApi(Build.VERSION_CODES.O)
builder.apply { private fun showAutofillDialog(pref: SwitchPreference) {
switch(PreferenceKeys.AUTOFILL_ENABLE) { val observer = LifecycleEventObserver { _, event ->
titleRes = R.string.pref_autofill_enable_title when (event) {
visible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O Lifecycle.Event.ON_RESUME -> {
defaultValue = isAutofillServiceEnabled pref.checked = 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
}
} }
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)
}
"$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
}
}
}
} }

View file

@ -20,37 +20,38 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class DirectorySelectionActivity : AppCompatActivity() { class DirectorySelectionActivity : AppCompatActivity() {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private val directorySelectAction = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> private val directorySelectAction =
if (uri == null) return@registerForActivityResult registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
if (uri == null) return@registerForActivityResult
d { "Selected repository URI is $uri" } d { "Selected repository URI is $uri" }
// TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile // TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile
val docId = DocumentsContract.getTreeDocumentId(uri) val docId = DocumentsContract.getTreeDocumentId(uri)
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
val path = if (split.size > 1) split[1] else split[0] val path = if (split.size > 1) split[1] else split[0]
val repoPath = "${Environment.getExternalStorageDirectory()}/$path" val repoPath = "${Environment.getExternalStorageDirectory()}/$path"
val prefs = sharedPrefs val prefs = sharedPrefs
d { "Selected repository path is $repoPath" } d { "Selected repository path is $repoPath" }
if (Environment.getExternalStorageDirectory().path == repoPath) { if (Environment.getExternalStorageDirectory().path == repoPath) {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle(resources.getString(R.string.sdcard_root_warning_title)) .setTitle(resources.getString(R.string.sdcard_root_warning_title))
.setMessage(resources.getString(R.string.sdcard_root_warning_message)) .setMessage(resources.getString(R.string.sdcard_root_warning_message))
.setPositiveButton(resources.getString(R.string.sdcard_root_warning_remove_everything)) { _, _ -> .setPositiveButton(resources.getString(R.string.sdcard_root_warning_remove_everything)) { _, _ ->
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) } prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) }
} }
.setNegativeButton(R.string.dialog_cancel, null) .setNegativeButton(R.string.dialog_cancel, null)
.show() .show()
} }
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) } prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) }
setResult(RESULT_OK) setResult(RESULT_OK)
finish() finish()
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
directorySelectAction.launch(null) directorySelectAction.launch(null)
} }
} }

View file

@ -22,83 +22,85 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider { class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider {
override fun provideSettings(builder: PreferenceScreen.Builder) { override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply { builder.apply {
val themeValues = activity.resources.getStringArray(R.array.app_theme_values) val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
val themeOptions = activity.resources.getStringArray(R.array.app_theme_options) val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
val themeItems = themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) } val themeItems = themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
singleChoice(PreferenceKeys.APP_THEME, themeItems) { singleChoice(PreferenceKeys.APP_THEME, themeItems) {
initialSelection = activity.resources.getString(R.string.app_theme_def) initialSelection = activity.resources.getString(R.string.app_theme_def)
titleRes = R.string.pref_app_theme_title titleRes = R.string.pref_app_theme_title
} }
val sortValues = activity.resources.getStringArray(R.array.sort_order_values) val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries) val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) } val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
singleChoice(PreferenceKeys.SORT_ORDER, sortItems) { singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
initialSelection = sortValues[0] initialSelection = sortValues[0]
titleRes = R.string.pref_sort_order_title titleRes = R.string.pref_sort_order_title
} }
checkBox(PreferenceKeys.FILTER_RECURSIVELY) { checkBox(PreferenceKeys.FILTER_RECURSIVELY) {
titleRes = R.string.pref_recursive_filter_title titleRes = R.string.pref_recursive_filter_title
summaryRes = R.string.pref_recursive_filter_summary summaryRes = R.string.pref_recursive_filter_summary
defaultValue = true defaultValue = true
} }
checkBox(PreferenceKeys.SEARCH_ON_START) { checkBox(PreferenceKeys.SEARCH_ON_START) {
titleRes = R.string.pref_search_on_start_title titleRes = R.string.pref_search_on_start_title
summaryRes = R.string.pref_search_on_start_summary summaryRes = R.string.pref_search_on_start_summary
defaultValue = false defaultValue = false
} }
checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) { checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
titleRes = R.string.pref_show_hidden_title titleRes = R.string.pref_show_hidden_title
summaryRes = R.string.pref_show_hidden_summary summaryRes = R.string.pref_show_hidden_summary
defaultValue = false defaultValue = false
} }
checkBox(PreferenceKeys.BIOMETRIC_AUTH) { checkBox(PreferenceKeys.BIOMETRIC_AUTH) {
titleRes = R.string.pref_biometric_auth_title titleRes = R.string.pref_biometric_auth_title
defaultValue = false defaultValue = false
}.apply { }
val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity) .apply {
if (!canAuthenticate) { val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
enabled = false if (!canAuthenticate) {
checked = false enabled = false
summaryRes = R.string.pref_biometric_auth_summary_error checked = false
} else { summaryRes = R.string.pref_biometric_auth_summary_error
summaryRes = R.string.pref_biometric_auth_summary } else {
onClick { summaryRes = R.string.pref_biometric_auth_summary
enabled = false onClick {
val isChecked = checked enabled = false
activity.sharedPrefs.edit { val isChecked = checked
BiometricAuthenticator.authenticate(activity) { result -> activity.sharedPrefs.edit {
when (result) { BiometricAuthenticator.authenticate(activity) { result ->
is BiometricAuthenticator.Result.Success -> { when (result) {
// Apply the changes is BiometricAuthenticator.Result.Success -> {
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked) // Apply the changes
enabled = true 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
} }
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
} }
}
} }
} }
}
} }

View file

@ -23,54 +23,59 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class MiscSettings(activity: FragmentActivity) : SettingsProvider { 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 { override fun createIntent(context: Context, input: Uri?): Intent {
return super.createIntent(context, input).apply { return super.createIntent(context, input).apply {
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or flags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
} Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
}
} }
}) { uri: Uri? -> }
if (uri == null) return@registerForActivityResult ) { uri: Uri? ->
val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri) if (uri == null) return@registerForActivityResult
val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
if (targetDirectory != null) { if (targetDirectory != null) {
val service = Intent(activity.applicationContext, PasswordExportService::class.java).apply { val service =
action = PasswordExportService.ACTION_EXPORT_PASSWORD Intent(activity.applicationContext, PasswordExportService::class.java).apply {
putExtra("uri", uri) action = PasswordExportService.ACTION_EXPORT_PASSWORD
} putExtra("uri", uri)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(service) activity.startForegroundService(service)
} else { } else {
activity.startService(service) activity.startService(service)
}
} }
}
} }
override fun provideSettings(builder: PreferenceScreen.Builder) { override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply { builder.apply {
pref(PreferenceKeys.EXPORT_PASSWORDS) { pref(PreferenceKeys.EXPORT_PASSWORDS) {
titleRes = R.string.prefs_export_passwords_title titleRes = R.string.prefs_export_passwords_title
summaryRes = R.string.prefs_export_passwords_summary summaryRes = R.string.prefs_export_passwords_summary
onClick { onClick {
storeExportAction.launch(null) storeExportAction.launch(null)
true 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
}
} }
}
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
}
} }
}
} }

View file

@ -29,85 +29,90 @@ import java.io.File
class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider { class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider {
private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs } private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs }
private val storeCustomXkpwdDictionaryAction = activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> private val storeCustomXkpwdDictionaryAction =
if (uri == null) return@registerForActivityResult activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@registerForActivityResult
Toast.makeText( Toast.makeText(
activity, activity,
activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path), activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() )
.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 inputStream = activity.contentResolver.openInputStream(uri)
val customDictFile = File(activity.filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream() val customDictFile = File(activity.filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream()
inputStream?.copyTo(customDictFile, 1024) inputStream?.copyTo(customDictFile, 1024)
inputStream?.close() inputStream?.close()
customDictFile.close() customDictFile.close()
} }
override fun provideSettings(builder: PreferenceScreen.Builder) { override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply { builder.apply {
val customDictPref = CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply { val customDictPref =
titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply {
summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title
summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd" summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on
onCheckedChange { visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
requestRebind() onCheckedChange {
true 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
}
} }
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
}
} }
}
} }

View file

@ -37,168 +37,165 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider { 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>) { private fun <T : FragmentActivity> launchActivity(clazz: Class<T>) {
activity.startActivity(Intent(activity, clazz)) activity.startActivity(Intent(activity, clazz))
} }
private fun selectExternalGitRepository() { private fun selectExternalGitRepository() {
MaterialAlertDialogBuilder(activity) MaterialAlertDialogBuilder(activity)
.setTitle(activity.resources.getString(R.string.external_repository_dialog_title)) .setTitle(activity.resources.getString(R.string.external_repository_dialog_title))
.setMessage(activity.resources.getString(R.string.external_repository_dialog_text)) .setMessage(activity.resources.getString(R.string.external_repository_dialog_text))
.setPositiveButton(R.string.dialog_ok) { _, _ -> .setPositiveButton(R.string.dialog_ok) { _, _ -> launchActivity(DirectorySelectionActivity::class.java) }
launchActivity(DirectorySelectionActivity::class.java) .setNegativeButton(R.string.dialog_cancel, null)
} .show()
.setNegativeButton(R.string.dialog_cancel, null) }
.show()
}
override fun provideSettings(builder: PreferenceScreen.Builder) { override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply { builder.apply {
checkBox(PreferenceKeys.REBASE_ON_PULL) { checkBox(PreferenceKeys.REBASE_ON_PULL) {
titleRes = R.string.pref_rebase_on_pull_title titleRes = R.string.pref_rebase_on_pull_title
summaryRes = R.string.pref_rebase_on_pull_summary summaryRes = R.string.pref_rebase_on_pull_summary
summaryOnRes = R.string.pref_rebase_on_pull_summary_on summaryOnRes = R.string.pref_rebase_on_pull_summary_on
defaultValue = true defaultValue = true
} }
pref(PreferenceKeys.GIT_SERVER_INFO) { pref(PreferenceKeys.GIT_SERVER_INFO) {
titleRes = R.string.pref_edit_git_server_settings titleRes = R.string.pref_edit_git_server_settings
visible = PasswordRepository.isGitRepo() visible = PasswordRepository.isGitRepo()
onClick { onClick {
launchActivity(GitServerConfigActivity::class.java) launchActivity(GitServerConfigActivity::class.java)
true 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()
}.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
}
}
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
}
}
} }
}
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()
}
.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
}
}
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
}
}
} }
}
} }

View file

@ -17,77 +17,79 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
private val miscSettings = MiscSettings(this) private val miscSettings = MiscSettings(this)
private val autofillSettings = AutofillSettings(this) private val autofillSettings = AutofillSettings(this)
private val passwordSettings = PasswordSettings(this) private val passwordSettings = PasswordSettings(this)
private val repositorySettings = RepositorySettings(this) private val repositorySettings = RepositorySettings(this)
private val generalSettings = GeneralSettings(this) private val generalSettings = GeneralSettings(this)
private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate) private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
private val preferencesAdapter: PreferencesAdapter private val preferencesAdapter: PreferencesAdapter
get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
val screen = screen(this) { val screen =
subScreen { screen(this) {
titleRes = R.string.pref_category_general_title subScreen {
iconRes = R.drawable.app_settings_alt_24px titleRes = R.string.pref_category_general_title
generalSettings.provideSettings(this) 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)
}
} }
val adapter = PreferencesAdapter(screen) subScreen {
adapter.onScreenChangeListener = PreferencesAdapter.OnScreenChangeListener { subScreen, entering -> titleRes = R.string.pref_category_autofill_title
supportActionBar?.title = if (!entering) { iconRes = R.drawable.ic_wysiwyg_24px
getString(R.string.action_settings) autofillSettings.provideSettings(this)
} else {
getString(subScreen.titleRes)
}
} }
savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter") subScreen {
?.let(adapter::loadSavedState) titleRes = R.string.pref_category_passwords_title
binding.preferenceRecyclerView.adapter = adapter iconRes = R.drawable.ic_lock_open_24px
} passwordSettings.provideSettings(this)
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)
} }
} 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 onBackPressed() { override fun onSaveInstanceState(outState: Bundle) {
if (!preferencesAdapter.goBack()) super.onSaveInstanceState(outState)
super.onBackPressed() outState.putParcelable("adapter", preferencesAdapter.getSavedState())
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home ->
if (!preferencesAdapter.goBack()) {
super.onOptionsItemSelected(item)
} else {
true
}
else -> super.onOptionsItemSelected(item)
} }
}
override fun onBackPressed() {
if (!preferencesAdapter.goBack()) super.onBackPressed()
}
} }

View file

@ -7,13 +7,9 @@ package dev.msfjarvis.aps.ui.settings
import de.Maxr1998.modernpreferences.PreferenceScreen 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 { interface SettingsProvider {
/** /** Inserts the settings items for the class into the given [builder]. */
* Inserts the settings items for the class into the given [builder]. fun provideSettings(builder: PreferenceScreen.Builder)
*/
fun provideSettings(builder: PreferenceScreen.Builder)
} }

View file

@ -14,25 +14,24 @@ import dev.msfjarvis.aps.util.git.sshj.SshKey
class ShowSshKeyFragment : DialogFragment() { class ShowSshKeyFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val activity = requireActivity() val activity = requireActivity()
val publicKey = SshKey.sshPublicKey val publicKey = SshKey.sshPublicKey
return MaterialAlertDialogBuilder(requireActivity()).run { return MaterialAlertDialogBuilder(requireActivity()).run {
setMessage(getString(R.string.ssh_keygen_message, publicKey)) setMessage(getString(R.string.ssh_keygen_message, publicKey))
setTitle(R.string.your_public_key) setTitle(R.string.your_public_key)
setNegativeButton(R.string.ssh_keygen_later) { _, _ -> setNegativeButton(R.string.ssh_keygen_later) { _, _ -> (activity as? SshKeyGenActivity)?.finish() }
(activity as? SshKeyGenActivity)?.finish() setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
} val sendIntent =
setPositiveButton(R.string.ssh_keygen_share) { _, _ -> Intent().apply {
val sendIntent = Intent().apply { action = Intent.ACTION_SEND
action = Intent.ACTION_SEND type = "text/plain"
type = "text/plain" putExtra(Intent.EXTRA_TEXT, publicKey)
putExtra(Intent.EXTRA_TEXT, publicKey) }
} startActivity(Intent.createChooser(sendIntent, null))
startActivity(Intent.createChooser(sendIntent, null)) (activity as? SshKeyGenActivity)?.finish()
(activity as? SshKeyGenActivity)?.finish() }
} create()
create()
}
} }
}
} }

View file

@ -30,135 +30,122 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) {
Rsa({ requireAuthentication -> Rsa({ requireAuthentication -> SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) }),
SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) Ecdsa({ requireAuthentication -> SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) }),
}), Ed25519({ requireAuthentication -> SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) }),
Ecdsa({ requireAuthentication ->
SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication)
}),
Ed25519({ requireAuthentication ->
SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication)
}),
} }
class SshKeyGenActivity : AppCompatActivity() { class SshKeyGenActivity : AppCompatActivity() {
private var keyGenType = KeyGenType.Ecdsa private var keyGenType = KeyGenType.Ecdsa
private val binding by viewBinding(ActivitySshKeygenBinding::inflate) private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
with(binding) { with(binding) {
generate.setOnClickListener { generate.setOnClickListener {
if (SshKey.exists) { if (SshKey.exists) {
MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
setTitle(R.string.ssh_keygen_existing_title) setTitle(R.string.ssh_keygen_existing_title)
setMessage(R.string.ssh_keygen_existing_message) setMessage(R.string.ssh_keygen_existing_message)
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> lifecycleScope.launch { generate() } }
lifecycleScope.launch { setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
generate() show()
} }
} } else {
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> lifecycleScope.launch { generate() }
finish()
}
show()
}
} else {
lifecycleScope.launch {
generate()
}
}
}
keyTypeGroup.check(R.id.key_type_ecdsa)
keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (isChecked) {
keyGenType = when (checkedId) {
R.id.key_type_ed25519 -> KeyGenType.Ed25519
R.id.key_type_ecdsa -> KeyGenType.Ecdsa
R.id.key_type_rsa -> KeyGenType.Rsa
else -> throw IllegalStateException("Impossible key type selection")
}
keyTypeExplanation.setText(when (keyGenType) {
KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
})
}
}
keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure
keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled
} }
}
keyTypeGroup.check(R.id.key_type_ecdsa)
keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (isChecked) {
keyGenType =
when (checkedId) {
R.id.key_type_ed25519 -> KeyGenType.Ed25519
R.id.key_type_ecdsa -> KeyGenType.Ecdsa
R.id.key_type_rsa -> KeyGenType.Rsa
else -> throw IllegalStateException("Impossible key type selection")
}
keyTypeExplanation.setText(
when (keyGenType) {
KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
}
)
}
}
keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure
keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled
} }
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {
onBackPressed() onBackPressed()
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
}
} }
}
private suspend fun generate() { private suspend fun generate() {
binding.generate.apply { binding.generate.apply {
text = getString(R.string.ssh_key_gen_generating_progress) text = getString(R.string.ssh_key_gen_generating_progress)
isEnabled = false isEnabled = false
} }
binding.generate.text = getString(R.string.ssh_key_gen_generating_progress) binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
val result = runCatching { val result = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val requireAuthentication = binding.keyRequireAuthentication.isChecked val requireAuthentication = binding.keyRequireAuthentication.isChecked
if (requireAuthentication) { if (requireAuthentication) {
val result = withContext(Dispatchers.Main) { val result =
suspendCoroutine<BiometricAuthenticator.Result> { cont -> withContext(Dispatchers.Main) {
BiometricAuthenticator.authenticate(this@SshKeyGenActivity, R.string.biometric_prompt_title_ssh_keygen) { suspendCoroutine<BiometricAuthenticator.Result> { cont ->
cont.resume(it) 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)
} }
if (result !is BiometricAuthenticator.Result.Success)
throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure))
} }
getEncryptedGitPrefs().edit { keyGenType.generateKey(requireAuthentication)
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()
} }
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() { private fun hideKeyboard() {
val imm = getSystemService<InputMethodManager>() ?: return val imm = getSystemService<InputMethodManager>() ?: return
var view = currentFocus var view = currentFocus
if (view == null) { if (view == null) {
view = View(this) view = View(this)
}
imm.hideSoftInputFromWindow(view.windowToken, 0)
} }
imm.hideSoftInputFromWindow(view.windowToken, 0)
}
} }

View file

@ -18,44 +18,44 @@ import dev.msfjarvis.aps.util.git.sshj.SshKey
class SshKeyImportActivity : AppCompatActivity() { class SshKeyImportActivity : AppCompatActivity() {
private val sshKeyImportAction = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> private val sshKeyImportAction =
if (uri == null) { registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
finish() if (uri == null) {
return@registerForActivityResult finish()
} return@registerForActivityResult
runCatching { }
SshKey.import(uri) runCatching {
Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show() SshKey.import(uri)
setResult(RESULT_OK) Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show()
finish() setResult(RESULT_OK)
}.onFailure { e -> finish()
MaterialAlertDialogBuilder(this) }
.setTitle(resources.getString(R.string.ssh_key_error_dialog_title)) .onFailure { e ->
.setMessage(e.message) MaterialAlertDialogBuilder(this)
.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() } .setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
.show() .setMessage(e.message)
.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() }
.show()
} }
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (SshKey.exists) { if (SshKey.exists) {
MaterialAlertDialogBuilder(this).run { MaterialAlertDialogBuilder(this).run {
setTitle(R.string.ssh_keygen_existing_title) setTitle(R.string.ssh_keygen_existing_title)
setMessage(R.string.ssh_keygen_existing_message) setMessage(R.string.ssh_keygen_existing_message)
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() }
importSshKey() setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
} setOnCancelListener { finish() }
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() } show()
setOnCancelListener { finish() } }
show() } else {
} importSshKey()
} else {
importSshKey()
}
} }
}
private fun importSshKey() { private fun importSshKey() {
sshKeyImportAction.launch(arrayOf("*/*")) sshKeyImportAction.launch(arrayOf("*/*"))
} }
} }

View file

@ -9,63 +9,63 @@ import androidx.recyclerview.widget.RecyclerView
class OnOffItemAnimator : DefaultItemAnimator() { class OnOffItemAnimator : DefaultItemAnimator() {
var isEnabled: Boolean = true var isEnabled: Boolean = true
set(value) { set(value) {
// Defer update until no animation is running anymore. // Defer update until no animation is running anymore.
isRunning { field = value } isRunning { field = value }
}
private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean {
dispatchAnimationFinished(viewHolder)
return false
} }
override fun animateAppearance( private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean {
viewHolder: RecyclerView.ViewHolder, dispatchAnimationFinished(viewHolder)
preLayoutInfo: ItemHolderInfo?, return false
postLayoutInfo: ItemHolderInfo }
): Boolean {
return if (isEnabled) {
super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo)
} else {
dontAnimate(viewHolder)
}
}
override fun animateChange( override fun animateAppearance(
oldHolder: RecyclerView.ViewHolder, viewHolder: RecyclerView.ViewHolder,
newHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo?,
preInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo
postInfo: ItemHolderInfo ): Boolean {
): Boolean { return if (isEnabled) {
return if (isEnabled) { super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo)
super.animateChange(oldHolder, newHolder, preInfo, postInfo) } else {
} else { dontAnimate(viewHolder)
dontAnimate(oldHolder)
}
} }
}
override fun animateDisappearance( override fun animateChange(
viewHolder: RecyclerView.ViewHolder, oldHolder: RecyclerView.ViewHolder,
preLayoutInfo: ItemHolderInfo, newHolder: RecyclerView.ViewHolder,
postLayoutInfo: ItemHolderInfo? preInfo: ItemHolderInfo,
): Boolean { postInfo: ItemHolderInfo
return if (isEnabled) { ): Boolean {
super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo) return if (isEnabled) {
} else { super.animateChange(oldHolder, newHolder, preInfo, postInfo)
dontAnimate(viewHolder) } else {
} dontAnimate(oldHolder)
} }
}
override fun animatePersistence( override fun animateDisappearance(
viewHolder: RecyclerView.ViewHolder, viewHolder: RecyclerView.ViewHolder,
preInfo: ItemHolderInfo, preLayoutInfo: ItemHolderInfo,
postInfo: ItemHolderInfo postLayoutInfo: ItemHolderInfo?
): Boolean { ): Boolean {
return if (isEnabled) { return if (isEnabled) {
super.animatePersistence(viewHolder, preInfo, postInfo) super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo)
} else { } else {
dontAnimate(viewHolder) dontAnimate(viewHolder)
}
} }
}
override fun animatePersistence(
viewHolder: RecyclerView.ViewHolder,
preInfo: ItemHolderInfo,
postInfo: ItemHolderInfo
): Boolean {
return if (isEnabled) {
super.animatePersistence(viewHolder, preInfo, postInfo)
} else {
dontAnimate(viewHolder)
}
}
} }

View file

@ -18,61 +18,69 @@ import dev.msfjarvis.aps.R
object BiometricAuthenticator { object BiometricAuthenticator {
private const val TAG = "BiometricAuthenticator" private const val TAG = "BiometricAuthenticator"
private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
sealed class Result { sealed class Result {
data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result() data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
data class Failure(val code: Int?, val message: CharSequence) : Result() data class Failure(val code: Int?, val message: CharSequence) : Result()
object HardwareUnavailableOrDisabled : Result() object HardwareUnavailableOrDisabled : Result()
object Cancelled : Result() object Cancelled : Result()
} }
fun canAuthenticate(activity: FragmentActivity): Boolean { fun canAuthenticate(activity: FragmentActivity): Boolean {
return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
} }
fun authenticate( fun authenticate(
activity: FragmentActivity, activity: FragmentActivity,
@StringRes dialogTitleRes: Int = R.string.biometric_prompt_title, @StringRes dialogTitleRes: Int = R.string.biometric_prompt_title,
callback: (Result) -> Unit callback: (Result) -> Unit
) { ) {
val authCallback = object : BiometricPrompt.AuthenticationCallback() { val authCallback =
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { object : BiometricPrompt.AuthenticationCallback() {
super.onAuthenticationError(errorCode, errString) override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" } super.onAuthenticationError(errorCode, errString)
callback(when (errorCode) { tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" }
BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED, callback(
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> { when (errorCode) {
Result.Cancelled BiometricPrompt.ERROR_CANCELED,
} BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE, BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> { Result.Cancelled
Result.HardwareUnavailableOrDisabled }
} BiometricPrompt.ERROR_HW_NOT_PRESENT,
else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString)) BiometricPrompt.ERROR_HW_UNAVAILABLE,
}) BiometricPrompt.ERROR_NO_BIOMETRICS,
} BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
Result.HardwareUnavailableOrDisabled
override fun onAuthenticationFailed() { }
super.onAuthenticationFailed() else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString))
callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error)))
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
callback(Result.Success(result.cryptoObject))
} }
)
} }
val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true
if (canAuthenticate(activity) || deviceHasKeyguard) { override fun onAuthenticationFailed() {
val promptInfo = BiometricPrompt.PromptInfo.Builder() super.onAuthenticationFailed()
.setTitle(activity.getString(dialogTitleRes)) callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error)))
.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)
} }
}
} }

View file

@ -27,163 +27,166 @@ import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
import java.io.File 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) @RequiresApi(Build.VERSION_CODES.R)
class Api30AutofillResponseBuilder(form: FillableForm) { class Api30AutofillResponseBuilder(form: FillableForm) {
private val formOrigin = form.formOrigin private val formOrigin = form.formOrigin
private val scenario = form.scenario private val scenario = form.scenario
private val ignoredIds = form.ignoredIds private val ignoredIds = form.ignoredIds
private val saveFlags = form.saveFlags private val saveFlags = form.saveFlags
private val clientState = form.toClientState() private val clientState = form.toClientState()
// We do not offer save when the only relevant field is a username field or there is no field. // 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 scenarioSupportsSave = scenario.hasPasswordFieldsToSave
private val canBeSaved = saveFlags != null && scenarioSupportsSave private val canBeSaved = saveFlags != null && scenarioSupportsSave
private fun makeIntentDataset( private fun makeIntentDataset(
context: Context, context: Context,
action: AutofillAction, action: AutofillAction,
intentSender: IntentSender, intentSender: IntentSender,
metadata: DatasetMetadata, metadata: DatasetMetadata,
imeSpec: InlinePresentationSpec?, imeSpec: InlinePresentationSpec?,
): Dataset { ): Dataset {
return Dataset.Builder(makeRemoteView(context, metadata)).run { return Dataset.Builder(makeRemoteView(context, metadata)).run {
fillWith(scenario, action, credentials = null) fillWith(scenario, action, credentials = null)
setAuthentication(intentSender) setAuthentication(intentSender)
if (imeSpec != null) { if (imeSpec != null) {
val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata) val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata)
if (inlinePresentation != null) { if (inlinePresentation != null) {
setInlinePresentation(inlinePresentation) setInlinePresentation(inlinePresentation)
}
}
build()
} }
}
build()
} }
}
private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? { private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
val metadata = makeFillMatchMetadata(context, file) val metadata = makeFillMatchMetadata(context, file)
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) 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 makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { private fun makeFillResponse(
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null context: Context,
val metadata = makeSearchAndFillMetadata(context) inlineSuggestionsRequest: InlineSuggestionsRequest?,
val intentSender = matchedFiles: List<File>
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) ): FillResponse? {
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec) var datasetCount = 0
} val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
return FillResponse.Builder().run {
private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { for (file in matchedFiles) {
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
val metadata = makeGenerateAndFillMetadata(context) datasetCount++
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) addDataset(it)
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()
} }
}
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? { // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
var datasetCount = 0 // See:
val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList() // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
return FillResponse.Builder().run { private fun makeSaveInfo(): SaveInfo? {
for (file in matchedFiles) { if (!canBeSaved) return null
makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let { check(saveFlags != null)
datasetCount++ val idsToSave = scenario.fieldsToSave.toTypedArray()
addDataset(it) if (idsToSave.isEmpty()) return null
} var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
} if (scenario.hasUsername) {
makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let { saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
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()
}
} }
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
setFlags(saveFlags)
build()
}
}
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE /** Creates and returns a suitable [FillResponse] to the Autofill framework. */
// See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) {
private fun makeSaveInfo(): SaveInfo? { AutofillMatcher.getMatchesFor(context, formOrigin)
if (!canBeSaved) return null .fold(
check(saveFlags != null) success = { matchedFiles ->
val idsToSave = scenario.fieldsToSave.toTypedArray() callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
if (idsToSave.isEmpty()) return null },
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD failure = { e ->
if (scenario.hasUsername) { e(e)
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME 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))
}
)
}
} }

View file

@ -21,173 +21,165 @@ import java.io.File
private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches" private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches"
private val Context.autofillAppMatches 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 const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches"
private val Context.autofillWebMatches 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 { private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences {
return when (formOrigin) { return when (formOrigin) {
is FormOrigin.App -> autofillAppMatches is FormOrigin.App -> autofillAppMatches
is FormOrigin.Web -> autofillWebMatches is FormOrigin.Web -> autofillWebMatches
} }
} }
class AutofillPublisherChangedException(val formOrigin: FormOrigin) : 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 { init {
require(formOrigin is FormOrigin.App) 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 { class AutofillMatcher {
companion object { companion object {
private const val MAX_NUM_MATCHES = 10 private const val MAX_NUM_MATCHES = 10
private const val PREFERENCE_PREFIX_TOKEN = "token;" private const val PREFERENCE_PREFIX_TOKEN = "token;"
private fun tokenKey(formOrigin: FormOrigin.App) = private fun tokenKey(formOrigin: FormOrigin.App) = "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
"$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
private const val PREFERENCE_PREFIX_MATCHES = "matches;" private const val PREFERENCE_PREFIX_MATCHES = "matches;"
private fun matchesKey(formOrigin: FormOrigin) = private fun matchesKey(formOrigin: FormOrigin) = "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
"$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean { private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
return when (formOrigin) { return when (formOrigin) {
is FormOrigin.Web -> false is FormOrigin.Web -> false
is FormOrigin.App -> { is FormOrigin.App -> {
val packageName = formOrigin.identifier val packageName = formOrigin.identifier
val certificatesHash = computeCertificatesHash(context, packageName) val certificatesHash = computeCertificatesHash(context, packageName)
val storedCertificatesHash = val storedCertificatesHash = context.autofillAppMatches.getString(tokenKey(formOrigin), null) ?: return false
context.autofillAppMatches.getString(tokenKey(formOrigin), null) val hashHasChanged = certificatesHash != storedCertificatesHash
?: return false if (hashHasChanged) {
val hashHasChanged = certificatesHash != storedCertificatesHash e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
if (hashHasChanged) { true
e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" } } else {
true false
} else { }
false
}
}
}
}
private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
if (formOrigin is FormOrigin.App) {
val packageName = formOrigin.identifier
val certificatesHash = computeCertificatesHash(context, packageName)
context.autofillAppMatches.edit {
putString(tokenKey(formOrigin), certificatesHash)
}
}
// We don't need to store a hash for FormOrigin.Web since it can only originate from
// browsers we trust to verify the origin.
}
/**
* Get all Password Store entries that have already been associated with [formOrigin] by the
* user.
*
* If [formOrigin] represents an app and that app's certificates have changed since the
* first time the user associated an entry with it, an [AutofillPublisherChangedException]
* will be thrown.
*/
fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<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))
}
}
/**
* 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) }
}
}
} }
}
} }
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()) }
}
)
}
fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
context.matchPreferences(formOrigin).edit {
remove(matchesKey(formOrigin))
if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
}
}
/**
* Associates the store entry [file] with [formOrigin], such that future Autofill responses to
* requests from this app or website offer this entry as a dataset.
*
* The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of Android
* may crash when too many datasets are offered.
*/
fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
if (!file.exists()) return
if (hasFormOriginHashChanged(context, formOrigin)) {
// This should never happen since we already verified the publisher in
// getMatchesFor.
e { "App publisher changed between getMatchesFor and addMatchFor" }
throw AutofillPublisherChangedException(formOrigin)
}
val matchPreferences = context.matchPreferences(formOrigin)
val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
val newFiles = setOf(file.absoluteFile).union(matchedFiles)
if (newFiles.size > MAX_NUM_MATCHES) {
Toast.makeText(
context,
context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
Toast.LENGTH_LONG
)
.show()
return
}
matchPreferences.edit { putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) }
storeFormOriginHash(context, formOrigin)
d { "Stored match for $formOrigin" }
}
/**
* Goes through all existing matches and updates their associated entries by using [moveFromTo]
* as a lookup table and deleting the matches for files in [delete].
*/
fun updateMatches(
context: Context,
moveFromTo: Map<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) }
}
}
}
}
} }

View file

@ -17,125 +17,128 @@ import java.io.File
import java.nio.file.Paths import java.nio.file.Paths
enum class DirectoryStructure(val value: String) { enum class DirectoryStructure(val value: String) {
EncryptedUsername("encrypted_username"), EncryptedUsername("encrypted_username"),
FileBased("file"), FileBased("file"),
DirectoryBased("directory"); DirectoryBased("directory");
/** /**
* Returns the username associated to [file], following the convention of the current * Returns the username associated to [file], following the convention of the current
* [DirectoryStructure]. * [DirectoryStructure].
* *
* Examples: * Examples:
* - * --> null (EncryptedUsername) * - * --> null (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
* - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased) * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
*/ */
fun getUsernameFor(file: File): String? = when (this) { fun getUsernameFor(file: File): String? =
EncryptedUsername -> null when (this) {
FileBased -> file.nameWithoutExtension EncryptedUsername -> null
DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension FileBased -> file.nameWithoutExtension
DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
} }
/** /**
* Returns the origin identifier associated to [file], following the convention of the current * Returns the origin identifier associated to [file], following the convention of the current
* [DirectoryStructure]. * [DirectoryStructure].
* *
* At least one of [DirectoryStructure.getIdentifierFor] and * At least one of [DirectoryStructure.getIdentifierFor] and
* [DirectoryStructure.getAccountPartFor] will always return a non-null result. * [DirectoryStructure.getAccountPartFor] will always return a non-null result.
* *
* Examples: * Examples:
* - work/example.org.gpg --> example.org (EncryptedUsername) * - work/example.org.gpg --> example.org (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> example.org (FileBased) * - work/example.org/john@doe.org.gpg --> example.org (FileBased)
* - example.org.gpg --> example.org (FileBased, fallback) * - example.org.gpg --> example.org (FileBased, fallback)
* - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased) * - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
* - Temporary PIN.gpg --> null (DirectoryBased) * - Temporary PIN.gpg --> null (DirectoryBased)
*/ */
fun getIdentifierFor(file: File): String? = when (this) { fun getIdentifierFor(file: File): String? =
EncryptedUsername -> file.nameWithoutExtension when (this) {
FileBased -> file.parentFile?.name ?: file.nameWithoutExtension EncryptedUsername -> file.nameWithoutExtension
DirectoryBased -> file.parentFile?.parent FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
DirectoryBased -> file.parentFile?.parent
} }
/** /**
* Returns the path components of [file] until right before the component that contains the * Returns the path components of [file] until right before the component that contains the origin
* origin identifier according to the current [DirectoryStructure]. * identifier according to the current [DirectoryStructure].
* *
* Examples: * Examples:
* - work/example.org.gpg --> work (EncryptedUsername) * - work/example.org.gpg --> work (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> work (FileBased) * - work/example.org/john@doe.org.gpg --> work (FileBased)
* - example.org/john@doe.org.gpg --> null (FileBased) * - example.org/john@doe.org.gpg --> null (FileBased)
* - john@doe.org.gpg --> null (FileBased) * - john@doe.org.gpg --> null (FileBased)
* - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased) * - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
* - example.org/john@doe.org/password.gpg --> null (DirectoryBased) * - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
*/ */
fun getPathToIdentifierFor(file: File): String? = when (this) { fun getPathToIdentifierFor(file: File): String? =
EncryptedUsername -> file.parent when (this) {
FileBased -> file.parentFile?.parent EncryptedUsername -> file.parent
DirectoryBased -> file.parentFile?.parentFile?.parent FileBased -> file.parentFile?.parent
DirectoryBased -> file.parentFile?.parentFile?.parent
} }
/** /**
* Returns the path component of [file] following the origin identifier according to the current * Returns the path component of [file] following the origin identifier according to the current
* [DirectoryStructure] (without file extension). * [DirectoryStructure](without file extension).
* *
* At least one of [DirectoryStructure.getIdentifierFor] and * At least one of [DirectoryStructure.getIdentifierFor] and
* [DirectoryStructure.getAccountPartFor] will always return a non-null result. * [DirectoryStructure.getAccountPartFor] will always return a non-null result.
* *
* Examples: * Examples:
* - * --> null (EncryptedUsername) * - * --> null (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased) * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
* - example.org.gpg --> null (FileBased, fallback) * - example.org.gpg --> null (FileBased, fallback)
* - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased) * - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
*/ */
fun getAccountPartFor(file: File): String? = when (this) { fun getAccountPartFor(file: File): String? =
EncryptedUsername -> null when (this) {
FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null } EncryptedUsername -> null
DirectoryBased -> file.parentFile?.let { parentFile -> FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
"${parentFile.name}/${file.nameWithoutExtension}" DirectoryBased -> file.parentFile?.let { parentFile -> "${parentFile.name}/${file.nameWithoutExtension}" }
} ?: file.nameWithoutExtension ?: file.nameWithoutExtension
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) { fun getSaveFolderName(sanitizedIdentifier: String, username: String?) =
EncryptedUsername -> "/" when (this) {
FileBased -> sanitizedIdentifier EncryptedUsername -> "/"
DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString() FileBased -> sanitizedIdentifier
DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
} }
fun getSaveFileName(username: String?, identifier: String) = when (this) { fun getSaveFileName(username: String?, identifier: String) =
EncryptedUsername -> identifier when (this) {
FileBased -> username EncryptedUsername -> identifier
DirectoryBased -> "password" FileBased -> username
DirectoryBased -> "password"
} }
companion object { companion object {
val DEFAULT = FileBased val DEFAULT = FileBased
private val reverseMap = values().associateBy { it.value } private val reverseMap = values().associateBy { it.value }
fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
} }
} }
object AutofillPreferences { object AutofillPreferences {
fun directoryStructure(context: Context): DirectoryStructure { fun directoryStructure(context: Context): DirectoryStructure {
val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE) val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
return DirectoryStructure.fromValue(value) return DirectoryStructure.fromValue(value)
} }
fun credentialsFromStoreEntry( fun credentialsFromStoreEntry(
context: Context, context: Context,
file: File, file: File,
entry: PasswordEntry, entry: PasswordEntry,
directoryStructure: DirectoryStructure directoryStructure: DirectoryStructure
): Credentials { ): Credentials {
// Always give priority to a username stored in the encrypted extras // Always give priority to a username stored in the encrypted extras
val username = entry.username val username = entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
?: directoryStructure.getUsernameFor(file) return Credentials(username, entry.password, entry.calculateTotpCode())
?: context.getDefaultUsername() }
return Credentials(username, entry.password, entry.calculateTotpCode())
}
} }

View file

@ -30,176 +30,178 @@ import java.io.File
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
class AutofillResponseBuilder(form: FillableForm) { class AutofillResponseBuilder(form: FillableForm) {
private val formOrigin = form.formOrigin private val formOrigin = form.formOrigin
private val scenario = form.scenario private val scenario = form.scenario
private val ignoredIds = form.ignoredIds private val ignoredIds = form.ignoredIds
private val saveFlags = form.saveFlags private val saveFlags = form.saveFlags
private val clientState = form.toClientState() private val clientState = form.toClientState()
// We do not offer save when the only relevant field is a username field or there is no field. // 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 scenarioSupportsSave = scenario.hasPasswordFieldsToSave
private val canBeSaved = saveFlags != null && scenarioSupportsSave private val canBeSaved = saveFlags != null && scenarioSupportsSave
private fun makeIntentDataset( private fun makeIntentDataset(
context: Context, context: Context,
action: AutofillAction, action: AutofillAction,
intentSender: IntentSender, intentSender: IntentSender,
metadata: DatasetMetadata, metadata: DatasetMetadata,
): Dataset { ): Dataset {
return Dataset.Builder(makeRemoteView(context, metadata)).run { return Dataset.Builder(makeRemoteView(context, metadata)).run {
fillWith(scenario, action, credentials = null) fillWith(scenario, action, credentials = null)
setAuthentication(intentSender) setAuthentication(intentSender)
build() build()
}
}
private fun makeMatchDataset(context: Context, file: File): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
val metadata = makeFillMatchMetadata(context, file)
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
}
private fun makeSearchDataset(context: Context): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
val metadata = makeSearchAndFillMetadata(context)
val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
}
private fun makeGenerateDataset(context: Context): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
val metadata = makeGenerateAndFillMetadata(context)
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata)
}
private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
val metadata = makeFillOtpFromSmsMetadata(context)
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata)
}
private fun makePublisherChangedDataset(
context: Context,
publisherChangedException: AutofillPublisherChangedException,
): Dataset {
val metadata = makeWarningMetadata(context)
// If the user decides to trust the new publisher, they can choose reset the list of
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
val fillResponseAfterReset = makeFillResponse(context, emptyList())
val intentSender =
AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
context,
publisherChangedException,
fillResponseAfterReset
)
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
}
private fun makePublisherChangedResponse(
context: Context,
publisherChangedException: AutofillPublisherChangedException
): FillResponse {
return FillResponse.Builder().run {
addDataset(makePublisherChangedDataset(context, publisherChangedException))
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
}
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
// See:
// https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
private fun makeSaveInfo(): SaveInfo? {
if (!canBeSaved) return null
check(saveFlags != null)
val idsToSave = scenario.fieldsToSave.toTypedArray()
if (idsToSave.isEmpty()) return null
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
if (scenario.hasUsername) {
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
}
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
setFlags(saveFlags)
build()
}
}
private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
var datasetCount = 0
return FillResponse.Builder().run {
for (file in matchedFiles) {
makeMatchDataset(context, file)?.let {
datasetCount++
addDataset(it)
} }
} }
makeGenerateDataset(context)?.let {
private fun makeMatchDataset(context: Context, file: File): Dataset? { datasetCount++
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null addDataset(it)
val metadata = makeFillMatchMetadata(context, file) }
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) makeFillOtpFromSmsDataset(context)?.let {
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) datasetCount++
} addDataset(it)
}
makeSearchDataset(context)?.let {
private fun makeSearchDataset(context: Context): Dataset? { datasetCount++
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null addDataset(it)
val metadata = makeSearchAndFillMetadata(context) }
val intentSender = if (datasetCount == 0) return null
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata) setHeader(
} makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))
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) }
makeSaveInfo()?.let { setSaveInfo(it) }
setClientState(clientState)
setIgnoredIds(*ignoredIds.toTypedArray())
build()
} }
}
private fun makePublisherChangedResponse( /** Creates and returns a suitable [FillResponse] to the Autofill framework. */
context: Context, fun fillCredentials(context: Context, callback: FillCallback) {
publisherChangedException: AutofillPublisherChangedException AutofillMatcher.getMatchesFor(context, formOrigin)
): FillResponse { .fold(
return FillResponse.Builder().run { success = { matchedFiles -> callback.onSuccess(makeFillResponse(context, matchedFiles)) },
addDataset(makePublisherChangedDataset(context, publisherChangedException)) failure = { e ->
setIgnoredIds(*ignoredIds.toTypedArray()) e(e)
build() callback.onSuccess(makePublisherChangedResponse(context, e))
} }
} )
}
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE companion object {
// See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
private fun makeSaveInfo(): SaveInfo? { fun makeFillInDataset(
if (!canBeSaved) return null context: Context,
check(saveFlags != null) credentials: Credentials,
val idsToSave = scenario.fieldsToSave.toTypedArray() clientState: Bundle,
if (idsToSave.isEmpty()) return null action: AutofillAction
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD ): Dataset {
if (scenario.hasUsername) { val scenario = AutofillScenario.fromClientState(clientState)
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
} // though they are rarely shown.
return SaveInfo.Builder(saveDataTypes, idsToSave).run { // FIXME: We should clone the original dataset here and add the credentials to be filled
setFlags(saveFlags) // in. Otherwise, the entry in the cached list of datasets will be overwritten by the
build() // 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) {
private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? { Dataset.Builder()
var datasetCount = 0 } else {
return FillResponse.Builder().run { Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))
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()
}
}
/**
* 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()
}
} }
return builder.run {
if (scenario != null) fillWith(scenario, action, credentials)
else e { "Failed to recover scenario from client state" }
build()
}
} }
}
} }

View file

@ -26,88 +26,74 @@ import java.io.File
data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int) data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int)
fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews { fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews {
return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply { return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
setTextViewText(R.id.title, metadata.title) setTextViewText(R.id.title, metadata.title)
if (metadata.subtitle != null) { if (metadata.subtitle != null) {
setTextViewText(R.id.summary, metadata.subtitle) setTextViewText(R.id.summary, metadata.subtitle)
} else { } else {
setViewVisibility(R.id.summary, View.GONE) 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)
}
} }
if (metadata.iconRes != Resources.ID_NULL) {
setImageViewResource(R.id.icon, metadata.iconRes)
} else {
setViewVisibility(R.id.icon, View.GONE)
}
}
} }
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
fun makeInlinePresentation(context: Context, imeSpec: InlinePresentationSpec, metadata: DatasetMetadata): InlinePresentation? { fun makeInlinePresentation(
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) context: Context,
return null 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)) if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null
return null
val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0) val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0)
val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run { val slice =
setTitle(metadata.title) InlineSuggestionUi.newContentBuilder(launchIntent).run {
if (metadata.subtitle != null) setTitle(metadata.title)
setSubtitle(metadata.subtitle) if (metadata.subtitle != null) setSubtitle(metadata.subtitle)
setContentDescription(if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title) setContentDescription(
setStartIcon(Icon.createWithResource(context, metadata.iconRes)) if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title
build().slice )
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 { fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata {
val directoryStructure = AutofillPreferences.directoryStructure(context) val directoryStructure = AutofillPreferences.directoryStructure(context)
val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
val title = directoryStructure.getIdentifierFor(relativeFile) val title = directoryStructure.getIdentifierFor(relativeFile) ?: directoryStructure.getAccountPartFor(relativeFile)!!
?: directoryStructure.getAccountPartFor(relativeFile)!! val subtitle = directoryStructure.getAccountPartFor(relativeFile)
val subtitle = directoryStructure.getAccountPartFor(relativeFile) return DatasetMetadata(title, subtitle, R.drawable.ic_person_black_24dp)
return DatasetMetadata(
title,
subtitle,
R.drawable.ic_person_black_24dp
)
} }
fun makeSearchAndFillMetadata(context: Context) = DatasetMetadata( fun makeSearchAndFillMetadata(context: Context) =
context.getString(R.string.oreo_autofill_search_in_store), DatasetMetadata(context.getString(R.string.oreo_autofill_search_in_store), null, R.drawable.ic_search_black_24dp)
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), context.getString(R.string.oreo_autofill_generate_password),
null, null,
R.drawable.ic_autofill_new_password R.drawable.ic_autofill_new_password
) )
fun makeFillOtpFromSmsMetadata(context: Context) = DatasetMetadata( fun makeFillOtpFromSmsMetadata(context: Context) =
context.getString(R.string.oreo_autofill_fill_otp_from_sms), DatasetMetadata(context.getString(R.string.oreo_autofill_fill_otp_from_sms), null, R.drawable.ic_autofill_sms)
null,
R.drawable.ic_autofill_sms
)
fun makeEmptyMetadata() = DatasetMetadata( fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
"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_title),
context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary), context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary),
R.drawable.ic_warning_red_24dp R.drawable.ic_warning_red_24dp
) )
fun makeHeaderMetadata(title: String) = DatasetMetadata( fun makeHeaderMetadata(title: String) = DatasetMetadata(title, null, 0)
title,
null,
0
)

View file

@ -8,36 +8,33 @@ package dev.msfjarvis.aps.util.crypto
import me.msfjarvis.openpgpktx.util.OpenPgpUtils import me.msfjarvis.openpgpktx.util.OpenPgpUtils
sealed class GpgIdentifier { sealed class GpgIdentifier {
data class KeyId(val id: Long) : GpgIdentifier() data class KeyId(val id: Long) : GpgIdentifier()
data class UserId(val email: String) : GpgIdentifier() data class UserId(val email: String) : GpgIdentifier()
companion object { companion object {
@OptIn(ExperimentalUnsignedTypes::class) @OptIn(ExperimentalUnsignedTypes::class)
fun fromString(identifier: String): GpgIdentifier? { fun fromString(identifier: String): GpgIdentifier? {
if (identifier.isEmpty()) return null if (identifier.isEmpty()) return null
// Match long key IDs: // Match long key IDs:
// FF22334455667788 or 0xFF22334455667788 // FF22334455667788 or 0xFF22334455667788
val maybeLongKeyId = identifier.removePrefix("0x").takeIf { val maybeLongKeyId = identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{16}".toRegex()) }
it.matches("[a-fA-F0-9]{16}".toRegex()) if (maybeLongKeyId != null) {
} val keyId = maybeLongKeyId.toULong(16)
if (maybeLongKeyId != null) { return KeyId(keyId.toLong())
val keyId = maybeLongKeyId.toULong(16) }
return KeyId(keyId.toLong())
}
// Match fingerprints: // Match fingerprints:
// FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899 // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
val maybeFingerprint = identifier.removePrefix("0x").takeIf { val maybeFingerprint = identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{40}".toRegex()) }
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
if (maybeFingerprint != null) { // accepts
// Truncating to the long key ID is not a security issue since OpenKeychain only accepts // non-ambiguous key IDs.
// non-ambiguous key IDs. val keyId = maybeFingerprint.takeLast(16).toULong(16)
val keyId = maybeFingerprint.takeLast(16).toULong(16) return KeyId(keyId.toLong())
return KeyId(keyId.toLong()) }
}
return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) } return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) }
}
} }
}
} }

View file

@ -34,146 +34,115 @@ import dev.msfjarvis.aps.data.repo.PasswordRepository
import dev.msfjarvis.aps.util.git.operation.GitOperation import dev.msfjarvis.aps.util.git.operation.GitOperation
/** /**
* Extension function for [AlertDialog] that requests focus for the * Extension function for [AlertDialog] that requests focus for the view whose id is [id]. Solution
* view whose id is [id]. Solution based on a StackOverflow * based on a StackOverflow answer: https://stackoverflow.com/a/13056259/297261
* answer: https://stackoverflow.com/a/13056259/297261
*/ */
fun <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) { fun <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) {
setOnShowListener { setOnShowListener {
findViewById<T>(id)?.apply { findViewById<T>(id)?.apply {
setOnFocusChangeListener { v, _ -> setOnFocusChangeListener { v, _ ->
v.post { v.post { context.getSystemService<InputMethodManager>()?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) }
context.getSystemService<InputMethodManager>() }
?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) requestFocus()
}
}
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? val Context.autofillManager: AutofillManager?
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O) get() = getSystemService()
get() = getSystemService()
/** /** Get an instance of [ClipboardManager] */
* Get an instance of [ClipboardManager]
*/
val Context.clipboard 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") 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") 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 { private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences {
val masterKeyAlias = MasterKey.Builder(applicationContext) val masterKeyAlias = MasterKey.Builder(applicationContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM) return EncryptedSharedPreferences.create(
.build() applicationContext,
return EncryptedSharedPreferences.create( fileName,
applicationContext, masterKeyAlias,
fileName, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
masterKeyAlias, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, )
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
} }
/** /** Get an instance of [KeyguardManager] */
* Get an instance of [KeyguardManager]
*/
val Context.keyguardManager: KeyguardManager val Context.keyguardManager: KeyguardManager
get() = getSystemService()!! get() = getSystemService()!!
/** /** Get the default [SharedPreferences] instance */
* Get the default [SharedPreferences] instance
*/
val Context.sharedPrefs: SharedPreferences 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 { fun Context.resolveAttribute(attr: Int): Int {
val typedValue = TypedValue() val typedValue = TypedValue()
this.theme.resolveAttribute(attr, typedValue, true) this.theme.resolveAttribute(attr, typedValue, true)
return typedValue.data return typedValue.data
} }
/** /**
* Commit changes to the store from a [FragmentActivity] using * Commit changes to the store from a [FragmentActivity] using a custom implementation of
* a custom implementation of [GitOperation] * [GitOperation]
*/ */
suspend fun FragmentActivity.commitChange( suspend fun FragmentActivity.commitChange(
message: String, message: String,
): Result<Unit, Throwable> { ): Result<Unit, Throwable> {
if (!PasswordRepository.isGitRepo()) { if (!PasswordRepository.isGitRepo()) {
return Ok(Unit) return Ok(Unit)
} }
return object : GitOperation(this@commitChange) { return object : GitOperation(this@commitChange) {
override val commands = arrayOf( override val commands =
// Stage all files arrayOf(
git.add().addFilepattern("."), // Stage all files
// Populate the changed files count git.add().addFilepattern("."),
git.status(), // Populate the changed files count
// Commit everything! If anything changed, that is. git.status(),
git.commit().setAll(true).setMessage(message), // Commit everything! If anything changed, that is.
git.commit().setAll(true).setMessage(message),
) )
override fun preExecute(): Boolean { override fun preExecute(): Boolean {
d { "Committing with message: '$message'" } d { "Committing with message: '$message'" }
return true return true
} }
}.execute() }
.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 { 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 * Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a
* anchor it to a [com.google.android.material.floatingactionbutton.FloatingActionButton] * [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the
* if one exists in the [view] * [view]
*/ */
fun FragmentActivity.snackbar( fun FragmentActivity.snackbar(
view: View = findViewById(android.R.id.content), view: View = findViewById(android.R.id.content),
message: String, message: String,
length: Int = Snackbar.LENGTH_SHORT, length: Int = Snackbar.LENGTH_SHORT,
): Snackbar { ): Snackbar {
val snackbar = Snackbar.make(view, message, length) val snackbar = Snackbar.make(view, message, length)
snackbar.anchorView = findViewById(R.id.fab) snackbar.anchorView = findViewById(R.id.fab)
snackbar.show() snackbar.show()
return snackbar 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) 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 { fun String.base64(): String {
return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP) return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP)
} }

View file

@ -12,53 +12,40 @@ import java.util.Date
import org.eclipse.jgit.lib.ObjectId import org.eclipse.jgit.lib.ObjectId
import org.eclipse.jgit.revwalk.RevCommit 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" 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 { 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 { 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 { fun File.contains(other: File): Boolean {
if (!isDirectory) if (!isDirectory) return false
return false if (!other.exists()) return false
if (!other.exists()) val relativePath =
return false runCatching { other.relativeTo(this) }.getOrElse {
val relativePath = runCatching { return false
other.relativeTo(this)
}.getOrElse {
return false
} }
// Direct containment is equivalent to the relative path being equal to the filename. // Direct containment is equivalent to the relative path being equal to the filename.
return relativePath.path == other.name return relativePath.path == other.name
} }
/** /**
* Checks if this [File] is in the password repository directory as given * Checks if this [File] is in the password repository directory as given by
* by [PasswordRepository.getRepositoryDirectory] * [PasswordRepository.getRepositoryDirectory]
*/ */
fun File.isInsideRepository(): Boolean { 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() fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList()
/** /**
@ -67,7 +54,7 @@ fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toLis
* @see RevCommit.getId * @see RevCommit.getId
*/ */
val RevCommit.hash: String val RevCommit.hash: String
get() = ObjectId.toString(id) get() = ObjectId.toString(id)
/** /**
* Time this commit was made with second precision. * Time this commit was made with second precision.
@ -75,16 +62,16 @@ val RevCommit.hash: String
* @see RevCommit.commitTime * @see RevCommit.commitTime
*/ */
val RevCommit.time: Date val RevCommit.time: Date
get() { get() {
val epochSeconds = commitTime.toLong() val epochSeconds = commitTime.toLong()
val epochMilliseconds = epochSeconds * 1000 val epochMilliseconds = epochSeconds * 1000
return Date(epochMilliseconds) return Date(epochMilliseconds)
} }
/** /**
* Splits this [String] into an [Array] of [String]s, split on the UNIX LF line ending * Splits this [String] into an [Array] of [String] s, split on the UNIX LF line ending and stripped
* and stripped of any empty lines. * of any empty lines.
*/ */
fun String.splitLines(): Array<String> { fun String.splitLines(): Array<String> {
return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
} }

View file

@ -11,31 +11,31 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.commit import androidx.fragment.app.commit
import dev.msfjarvis.aps.R 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 { 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() fun Fragment.finish() = requireActivity().finish()
/** /**
* Perform a [commit] on this [FragmentManager] with custom animations and adding the [destinationFragment] * Perform a [commit] on this [FragmentManager] with custom animations and adding the
* to the fragment backstack * [destinationFragment] to the fragment backstack
*/ */
fun FragmentManager.performTransactionWithBackStack(destinationFragment: Fragment, @IdRes containerViewId: Int = android.R.id.content) { fun FragmentManager.performTransactionWithBackStack(
commit { destinationFragment: Fragment,
beginTransaction() @IdRes containerViewId: Int = android.R.id.content
addToBackStack(destinationFragment.tag) ) {
setCustomAnimations( commit {
R.animator.slide_in_left, beginTransaction()
R.animator.slide_out_left, addToBackStack(destinationFragment.tag)
R.animator.slide_in_right, setCustomAnimations(
R.animator.slide_out_right) R.animator.slide_in_left,
replace(containerViewId, destinationFragment) R.animator.slide_out_left,
} R.animator.slide_in_right,
R.animator.slide_out_right
)
replace(containerViewId, destinationFragment)
}
} }

View file

@ -5,7 +5,6 @@
package dev.msfjarvis.aps.util.extensions package dev.msfjarvis.aps.util.extensions
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@ -18,48 +17,49 @@ import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty 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>( class FragmentViewBindingDelegate<T : ViewBinding>(val fragment: Fragment, val viewBindingFactory: (View) -> T) :
val fragment: Fragment, ReadOnlyProperty<Fragment, T> {
val viewBindingFactory: (View) -> T
) : ReadOnlyProperty<Fragment, T> {
private var binding: T? = null private var binding: T? = null
init { init {
fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { fragment.lifecycle.addObserver(
override fun onCreate(owner: LifecycleOwner) { object : DefaultLifecycleObserver {
fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> override fun onCreate(owner: LifecycleOwner) {
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
override fun onDestroy(owner: LifecycleOwner) { viewLifecycleOwner.lifecycle.addObserver(
binding = null 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
} }
override fun getValue(thisRef: Fragment, property: KProperty<*>): T { val lifecycle = fragment.viewLifecycleOwner.lifecycle
val binding = binding if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
if (binding != null) { throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
return binding
}
val lifecycle = fragment.viewLifecycleOwner.lifecycle
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
}
return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
} }
return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
}
} }
fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) = fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
FragmentViewBindingDelegate(this, viewBindingFactory) FragmentViewBindingDelegate(this, viewBindingFactory)
inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) = inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) =
lazy(LazyThreadSafetyMode.NONE) { lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) }
bindingInflater.invoke(layoutInflater)
}

View file

@ -12,57 +12,54 @@ import dev.msfjarvis.aps.R
import java.net.UnknownHostException 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)) { 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]. */
* Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
*/
sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error) object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error)
object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error) object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error)
} }
/** /** Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. */
* Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
*/
sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
object NonFastForward : PushException(R.string.git_push_nff_error) object NonFastForward : PushException(R.string.git_push_nff_error)
object RemoteRejected : PushException(R.string.git_push_other_error) object RemoteRejected : PushException(R.string.git_push_other_error)
class Generic(message: String) : PushException(R.string.git_push_generic_error, message) class Generic(message: String) : PushException(R.string.git_push_generic_error, message)
} }
} }
object ErrorMessages { object ErrorMessages {
operator fun get(throwable: Throwable?): String { operator fun get(throwable: Throwable?): String {
val resources = Application.instance.resources val resources = Application.instance.resources
if (throwable == null) return resources.getString(R.string.git_unknown_error) if (throwable == null) return resources.getString(R.string.git_unknown_error)
return when (val rootCause = rootCause(throwable)) { return when (val rootCause = rootCause(throwable)) {
is GitException -> rootCause.message is GitException -> rootCause.message
is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message) is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message)
else -> throwable.message ?: resources.getString(R.string.git_unknown_error) else -> throwable.message ?: resources.getString(R.string.git_unknown_error)
}
} }
}
private fun rootCause(throwable: Throwable): Throwable { private fun rootCause(throwable: Throwable): Throwable {
var cause = throwable var cause = throwable
while (cause.cause != null) { while (cause.cause != null) {
if (cause is GitException) break if (cause is GitException) break
val nextCause = cause.cause!! val nextCause = cause.cause!!
if (nextCause is RemoteException) break if (nextCause is RemoteException) break
cause = nextCause cause = nextCause
}
return cause
} }
return cause
}
} }

View file

@ -26,96 +26,87 @@ import org.eclipse.jgit.lib.PersonIdent
import org.eclipse.jgit.transport.RemoteRefUpdate import org.eclipse.jgit.transport.RemoteRefUpdate
class GitCommandExecutor( class GitCommandExecutor(
private val activity: FragmentActivity, private val activity: FragmentActivity,
private val operation: GitOperation, private val operation: GitOperation,
) { ) {
suspend fun execute(): Result<Unit, Throwable> { suspend fun execute(): Result<Unit, Throwable> {
val snackbar = activity.snackbar( val snackbar =
message = activity.resources.getString(R.string.git_operation_running), activity.snackbar(
length = Snackbar.LENGTH_INDEFINITE, message = activity.resources.getString(R.string.git_operation_running),
) length = Snackbar.LENGTH_INDEFINITE,
// Count the number of uncommitted files )
var nbChanges = 0 // Count the number of uncommitted files
return runCatching { var nbChanges = 0
for (command in operation.commands) { return runCatching {
when (command) { for (command in operation.commands) {
is StatusCommand -> { when (command) {
val res = withContext(Dispatchers.IO) { is StatusCommand -> {
command.call() val res = withContext(Dispatchers.IO) { command.call() }
} nbChanges = res.uncommittedChanges.size
nbChanges = res.uncommittedChanges.size }
} is CommitCommand -> {
is CommitCommand -> { // the previous status will eventually be used to avoid a commit
// the previous status will eventually be used to avoid a commit if (nbChanges > 0) {
if (nbChanges > 0) { withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { val name = GitSettings.authorName.ifEmpty { "root" }
val name = GitSettings.authorName.ifEmpty { "root" } val email = GitSettings.authorEmail.ifEmpty { "localhost" }
val email = GitSettings.authorEmail.ifEmpty { "localhost" } val identity = PersonIdent(name, email)
val identity = PersonIdent(name, email) command.setAuthor(identity).setCommitter(identity).call()
command.setAuthor(identity).setCommitter(identity).call() }
}
}
}
is PullCommand -> {
val result = withContext(Dispatchers.IO) {
command.call()
}
if (result.rebaseResult != null) {
if (!result.rebaseResult.status.isSuccessful) {
throw PullException.PullRebaseFailed
}
} else if (result.mergeResult != null) {
if (!result.mergeResult.mergeStatus.isSuccessful) {
throw PullException.PullMergeFailed
}
}
}
is PushCommand -> {
val results = withContext(Dispatchers.IO) {
command.call()
}
for (result in results) {
// Code imported (modified) from Gerrit PushOp, license Apache v2
for (rru in result.remoteUpdates) {
when (rru.status) {
RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward
RemoteRefUpdate.Status.REJECTED_NODELETE,
RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
RemoteRefUpdate.Status.NON_EXISTING,
RemoteRefUpdate.Status.NOT_ATTEMPTED,
-> throw PushException.Generic(rru.status.name)
RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
throw if ("non-fast-forward" == rru.message) {
PushException.RemoteRejected
} else {
PushException.Generic(rru.message)
}
}
RemoteRefUpdate.Status.UP_TO_DATE -> {
withContext(Dispatchers.Main) {
Toast.makeText(
activity.applicationContext,
activity.applicationContext.getString(R.string.git_push_up_to_date),
Toast.LENGTH_SHORT
).show()
}
}
else -> {
}
}
}
}
}
else -> {
withContext(Dispatchers.IO) {
command.call()
}
}
}
} }
}.also { }
snackbar.dismiss() is PullCommand -> {
val result = withContext(Dispatchers.IO) { command.call() }
if (result.rebaseResult != null) {
if (!result.rebaseResult.status.isSuccessful) {
throw PullException.PullRebaseFailed
}
} else if (result.mergeResult != null) {
if (!result.mergeResult.mergeStatus.isSuccessful) {
throw PullException.PullMergeFailed
}
}
}
is PushCommand -> {
val results = withContext(Dispatchers.IO) { command.call() }
for (result in results) {
// Code imported (modified) from Gerrit PushOp, license Apache v2
for (rru in result.remoteUpdates) {
when (rru.status) {
RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward
RemoteRefUpdate.Status.REJECTED_NODELETE,
RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
RemoteRefUpdate.Status.NON_EXISTING,
RemoteRefUpdate.Status.NOT_ATTEMPTED, -> throw PushException.Generic(rru.status.name)
RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
throw if ("non-fast-forward" == rru.message) {
PushException.RemoteRejected
} else {
PushException.Generic(rru.message)
}
}
RemoteRefUpdate.Status.UP_TO_DATE -> {
withContext(Dispatchers.Main) {
Toast.makeText(
activity.applicationContext,
activity.applicationContext.getString(R.string.git_push_up_to_date),
Toast.LENGTH_SHORT
)
.show()
}
}
else -> {}
}
}
}
}
else -> {
withContext(Dispatchers.IO) { command.call() }
}
} }
}
} }
.also { snackbar.dismiss() }
}
} }

View file

@ -15,41 +15,37 @@ import org.eclipse.jgit.api.Git
import org.eclipse.jgit.revwalk.RevCommit import org.eclipse.jgit.revwalk.RevCommit
private fun commits(): Iterable<RevCommit> { private fun commits(): Iterable<RevCommit> {
val repo = PasswordRepository.getRepository(null) val repo = PasswordRepository.getRepository(null)
if (repo == null) { if (repo == null) {
e { "Could not access git repository" } e { "Could not access git repository" }
return listOf() return listOf()
} }
return runCatching { return runCatching { Git(repo).log().call() }.getOrElse { e ->
Git(repo).log().call() e(e) { "Failed to obtain git commits" }
}.getOrElse { e -> listOf()
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. * All commits are acquired on the first request to this object.
*/ */
class GitLogModel { class GitLogModel {
// All commits are acquired here at once. Acquiring the commits in batches would not have been // 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 // 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. // 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. // 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 // Additionally, tests with 1000 commits in the log have not produced a significant delay in the
// user experience. // user experience.
private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) { private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) {
commits().map { commits().map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }.toMutableList()
GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }
}.toMutableList() val size = cache.size
}
val size = cache.size
fun get(index: Int): GitCommit? { fun get(index: Int): GitCommit? {
if (index >= size) e { "Cannot get git commit with index $index. There are only $size." } if (index >= size) e { "Cannot get git commit with index $index. There are only $size." }
return cache.getOrNull(index) return cache.getOrNull(index)
} }
} }

View file

@ -13,44 +13,45 @@ import org.eclipse.jgit.lib.RepositoryState
class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
private val merging = repository.repositoryState == RepositoryState.MERGING private val merging = repository.repositoryState == RepositoryState.MERGING
private val resetCommands = arrayOf( private val resetCommands =
// git checkout -b conflict-branch arrayOf(
git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"), // git checkout -b conflict-branch
// push the changes git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"),
git.push().setRemote("origin"), // push the changes
// switch back to ${gitBranch} git.push().setRemote("origin"),
git.checkout().setName(remoteBranch), // switch back to ${gitBranch}
git.checkout().setName(remoteBranch),
) )
override val commands by lazy(LazyThreadSafetyMode.NONE) { override val commands by lazy(LazyThreadSafetyMode.NONE) {
if (merging) { if (merging) {
// We need to run some non-command operations first // We need to run some non-command operations first
repository.writeMergeCommitMsg(null) repository.writeMergeCommitMsg(null)
repository.writeMergeHeads(null) repository.writeMergeHeads(null)
arrayOf( arrayOf(
// reset hard back to our local HEAD // reset hard back to our local HEAD
git.reset().setMode(ResetCommand.ResetType.HARD), git.reset().setMode(ResetCommand.ResetType.HARD),
*resetCommands, *resetCommands,
) )
} else {
arrayOf(
// abort the rebase
git.rebase().setOperation(RebaseCommand.Operation.ABORT),
*resetCommands,
)
}
}
override fun preExecute() = if (!git.repository.repositoryState.isRebasing && !merging) {
MaterialAlertDialogBuilder(callingActivity)
.setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
.setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded))
.setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ ->
callingActivity.finish()
}.show()
false
} else { } else {
true arrayOf(
// abort the rebase
git.rebase().setOperation(RebaseCommand.Operation.ABORT),
*resetCommands,
)
}
}
override fun preExecute() =
if (!git.repository.repositoryState.isRebasing && !merging) {
MaterialAlertDialogBuilder(callingActivity)
.setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
.setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded))
.setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() }
.show()
false
} else {
true
} }
} }

View file

@ -16,7 +16,8 @@ import org.eclipse.jgit.api.GitCommand
*/ */
class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) { class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) {
override val commands: Array<GitCommand<out Any>> = arrayOf( override val commands: Array<GitCommand<out Any>> =
Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri), arrayOf(
Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri),
) )
} }

View file

@ -24,80 +24,71 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
class CredentialFinder( class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) : InteractivePasswordFinder() {
val callingActivity: FragmentActivity,
val authMode: AuthMode
) : InteractivePasswordFinder() {
override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) { override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
val gitOperationPrefs = callingActivity.getEncryptedGitPrefs() val gitOperationPrefs = callingActivity.getEncryptedGitPrefs()
val credentialPref: String val credentialPref: String
@StringRes val messageRes: Int @StringRes val messageRes: Int
@StringRes val hintRes: Int @StringRes val hintRes: Int
@StringRes val rememberRes: Int @StringRes val rememberRes: Int
@StringRes val errorRes: Int @StringRes val errorRes: Int
when (authMode) { when (authMode) {
AuthMode.SshKey -> { AuthMode.SshKey -> {
credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
messageRes = R.string.passphrase_dialog_text messageRes = R.string.passphrase_dialog_text
hintRes = R.string.ssh_keygen_passphrase hintRes = R.string.ssh_keygen_passphrase
rememberRes = R.string.git_operation_remember_passphrase rememberRes = R.string.git_operation_remember_passphrase
errorRes = R.string.git_operation_wrong_passphrase errorRes = R.string.git_operation_wrong_passphrase
} }
AuthMode.Password -> { AuthMode.Password -> {
// Could be either an SSH or an HTTPS password // Could be either an SSH or an HTTPS password
credentialPref = PreferenceKeys.HTTPS_PASSWORD credentialPref = PreferenceKeys.HTTPS_PASSWORD
messageRes = R.string.password_dialog_text messageRes = R.string.password_dialog_text
hintRes = R.string.git_operation_hint_password hintRes = R.string.git_operation_hint_password
rememberRes = R.string.git_operation_remember_password rememberRes = R.string.git_operation_remember_password
errorRes = R.string.git_operation_wrong_password errorRes = R.string.git_operation_wrong_password
} }
else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords") 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()
}
} else {
cont.resume(storedCredential)
}
} }
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()
}
} else {
cont.resume(storedCredential)
}
}
} }

View file

@ -50,170 +50,167 @@ import org.eclipse.jgit.transport.URIish
*/ */
abstract class GitOperation(protected val callingActivity: FragmentActivity) { abstract class GitOperation(protected val callingActivity: FragmentActivity) {
abstract val commands: Array<GitCommand<out Any>> abstract val commands: Array<GitCommand<out Any>>
private val hostKeyFile = callingActivity.filesDir.resolve(".host_key") private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
private var sshSessionFactory: SshjSessionFactory? = null private var sshSessionFactory: SshjSessionFactory? = null
protected val repository = PasswordRepository.getRepository(null)!! protected val repository = PasswordRepository.getRepository(null)!!
protected val git = Git(repository) protected val git = Git(repository)
protected val remoteBranch = GitSettings.branch protected val remoteBranch = GitSettings.branch
private val authActivity get() = callingActivity as ContinuationContainerActivity private val authActivity
get() = callingActivity as ContinuationContainerActivity
private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() { private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() {
private var cachedPassword: CharArray? = null private var cachedPassword: CharArray? = null
override fun isInteractive() = true override fun isInteractive() = true
override fun get(uri: URIish?, vararg items: CredentialItem): Boolean { override fun get(uri: URIish?, vararg items: CredentialItem): Boolean {
for (item in items) { for (item in items) {
when (item) { when (item) {
is CredentialItem.Username -> item.value = uri?.user is CredentialItem.Username -> item.value = uri?.user
is CredentialItem.Password -> { is CredentialItem.Password -> {
item.value = cachedPassword?.clone() item.value =
?: passwordFinder.reqPassword(null).also { cachedPassword?.clone() ?: passwordFinder.reqPassword(null).also { cachedPassword = it.clone() }
cachedPassword = it.clone() }
} else -> UnsupportedCredentialItem(uri, item.javaClass.name)
} }
else -> UnsupportedCredentialItem(uri, item.javaClass.name) }
return true
}
override fun supports(vararg items: CredentialItem) =
items.all { it is CredentialItem.Username || it is CredentialItem.Password }
override fun reset(uri: URIish?) {
cachedPassword?.fill(0.toChar())
cachedPassword = null
}
}
private fun getSshKey(make: Boolean) {
runCatching {
val intent =
if (make) {
Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java)
} else {
Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java)
}
callingActivity.startActivity(intent)
}
.onFailure { e -> 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)
}
}
/** 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)
}
} }
} }
return true when (result) {
} is BiometricAuthenticator.Result.Success -> {
registerAuthProviders(SshAuthMethod.SshKey(authActivity))
override fun supports(vararg items: CredentialItem) = items.all { }
it is CredentialItem.Username || it is CredentialItem.Password is BiometricAuthenticator.Result.Cancelled -> {
}
override fun reset(uri: URIish?) {
cachedPassword?.fill(0.toChar())
cachedPassword = null
}
}
private fun getSshKey(make: Boolean) {
runCatching {
val intent = if (make) {
Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java)
} else {
Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java)
}
callingActivity.startActivity(intent)
}.onFailure { e ->
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)
}
}
/**
* 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)
}
}
}
when (result) {
is BiometricAuthenticator.Result.Success -> {
registerAuthProviders(SshAuthMethod.SshKey(authActivity))
}
is BiometricAuthenticator.Result.Cancelled -> {
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
}
is BiometricAuthenticator.Result.Failure -> {
throw IllegalStateException("Biometric authentication failures should be ignored")
}
else -> {
// There is a chance we succeed if the user recently confirmed
// their screen lock. Doing so would have a potential to confuse
// users though, who might deduce that the screen lock
// protection is not effective. Hence, we fail with an error.
Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show()
callingActivity.finish()
}
}
} else {
registerAuthProviders(SshAuthMethod.SshKey(authActivity))
}
} else {
onMissingSshKeyFile()
// This would correctly cancel the operation but won't surface a user-visible
// error, allowing users to make the SSH key selection.
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)) 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)) } else {
AuthMode.Password -> { registerAuthProviders(SshAuthMethod.SshKey(authActivity))
val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password)) }
registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) } else {
} onMissingSshKeyFile()
AuthMode.None -> { // 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. */
* Called before execution of the Git operation. open fun preExecute() = true
* Return false to cancel.
*/
open fun preExecute() = true
private suspend fun postExecute() { private suspend fun postExecute() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { sshSessionFactory?.close() }
sshSessionFactory?.close() }
}
}
companion object { companion object {
/** /** Timeout in seconds before [TransportCommand] will abort a stalled IO operation. */
* Timeout in seconds before [TransportCommand] will abort a stalled IO operation. private const val CONNECT_TIMEOUT = 10
*/ }
private const val CONNECT_TIMEOUT = 10
}
} }

View file

@ -8,27 +8,28 @@ import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
import org.eclipse.jgit.api.GitCommand import org.eclipse.jgit.api.GitCommand
class PullOperation( class PullOperation(
callingActivity: ContinuationContainerActivity, callingActivity: ContinuationContainerActivity,
rebase: Boolean, rebase: Boolean,
) : GitOperation(callingActivity) { ) : GitOperation(callingActivity) {
/** /**
* The story of why the pull operation is committing files goes like this: Once upon a time when * 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 * 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. * 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] * 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, * 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 * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
* replicating [SyncOperation] but leaving the pushing part to [PushOperation]. * replicating [SyncOperation] but leaving the pushing part to [PushOperation].
*/ */
override val commands: Array<GitCommand<out Any>> = arrayOf( override val commands: Array<GitCommand<out Any>> =
// Stage all files arrayOf(
git.add().addFilepattern("."), // Stage all files
// Populate the changed files count git.add().addFilepattern("."),
git.status(), // Populate the changed files count
// Commit everything! If needed, obviously. git.status(),
git.commit().setAll(true).setMessage("[Android Password Store] Sync"), // Commit everything! If needed, obviously.
// Pull and rebase on top of the remote branch git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
git.pull().setRebase(rebase).setRemote("origin"), // Pull and rebase on top of the remote branch
git.pull().setRebase(rebase).setRemote("origin"),
) )
} }

View file

@ -9,7 +9,8 @@ import org.eclipse.jgit.api.GitCommand
class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
override val commands: Array<GitCommand<out Any>> = arrayOf( override val commands: Array<GitCommand<out Any>> =
git.push().setPushAll().setRemote("origin"), arrayOf(
git.push().setPushAll().setRemote("origin"),
) )
} }

View file

@ -9,15 +9,18 @@ import org.eclipse.jgit.api.ResetCommand
class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
override val commands = arrayOf( override val commands =
// Stage all files arrayOf(
git.add().addFilepattern("."), // Stage all files
// Fetch everything from the origin remote git.add().addFilepattern("."),
git.fetch().setRemote("origin"), // Fetch everything from the origin remote
// Do a hard reset to the remote branch. Equivalent to git reset --hard origin/$remoteBranch git.fetch().setRemote("origin"),
git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD), // Do a hard reset to the remote branch. Equivalent to git reset --hard
// Force-create $remoteBranch if it doesn't exist. This covers the case where you switched // origin/$remoteBranch
// branches from 'master' to anything else. git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD),
git.branchCreate().setName(remoteBranch).setForce(true), // 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),
) )
} }

View file

@ -7,20 +7,21 @@ package dev.msfjarvis.aps.util.git.operation
import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
class SyncOperation( class SyncOperation(
callingActivity: ContinuationContainerActivity, callingActivity: ContinuationContainerActivity,
rebase: Boolean, rebase: Boolean,
) : GitOperation(callingActivity) { ) : GitOperation(callingActivity) {
override val commands = arrayOf( override val commands =
// Stage all files arrayOf(
git.add().addFilepattern("."), // Stage all files
// Populate the changed files count git.add().addFilepattern("."),
git.status(), // Populate the changed files count
// Commit everything! If needed, obviously. git.status(),
git.commit().setAll(true).setMessage("[Android Password Store] Sync"), // Commit everything! If needed, obviously.
// Pull and rebase on top of the remote branch git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
git.pull().setRebase(rebase).setRemote("origin"), // Pull and rebase on top of the remote branch
// Push it all back git.pull().setRebase(rebase).setRemote("origin"),
git.push().setPushAll().setRemote("origin"), // Push it all back
git.push().setPushAll().setRemote("origin"),
) )
} }

View file

@ -14,24 +14,21 @@ import kotlin.coroutines.resumeWithException
import net.schmizz.sshj.common.DisconnectReason import net.schmizz.sshj.common.DisconnectReason
import net.schmizz.sshj.userauth.UserAuthException 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 { open class ContinuationContainerActivity : AppCompatActivity {
constructor() : super() constructor() : super()
constructor(@LayoutRes layoutRes: Int) : super(layoutRes) constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
var stashedCont: Continuation<Intent>? = null var stashedCont: Continuation<Intent>? = null
val continueAfterUserInteraction = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> val continueAfterUserInteraction =
stashedCont?.let { cont -> registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
stashedCont = null stashedCont?.let { cont ->
val data = result.data stashedCont = null
if (data != null) val data = result.data
cont.resume(data) if (data != null) cont.resume(data)
else else cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER))
cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER)) }
}
} }
} }

View file

@ -38,162 +38,170 @@ import org.openintents.ssh.authentication.response.Response
import org.openintents.ssh.authentication.response.SigningResponse import org.openintents.ssh.authentication.response.SigningResponse
import org.openintents.ssh.authentication.response.SshPublicKeyResponse 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) { suspend fun prepareAndUse(
withContext(Dispatchers.Main) { activity: ContinuationContainerActivity,
OpenKeychainKeyProvider(activity) block: (provider: OpenKeychainKeyProvider) -> Unit
}.prepareAndUse(block) ) {
} withContext(Dispatchers.Main) { OpenKeychainKeyProvider(activity) }.prepareAndUse(block)
} }
}
private sealed class ApiResponse { private sealed class ApiResponse {
data class Success(val response: Response) : ApiResponse() data class Success(val response: Response) : ApiResponse()
data class GeneralError(val exception: Exception) : ApiResponse() data class GeneralError(val exception: Exception) : ApiResponse()
data class NoSuchKey(val exception: Exception) : ApiResponse() data class NoSuchKey(val exception: Exception) : ApiResponse()
}
private val context = activity.applicationContext
private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER)
private val preferences = context.sharedPrefs
private lateinit var sshServiceApi: SshAuthenticationApi
private var keyId
get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)
set(value) {
preferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) }
} }
private var publicKey: PublicKey? = null
private var privateKey: OpenKeychainPrivateKey? = null
private val context = activity.applicationContext private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) {
private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER) prepare()
private val preferences = context.sharedPrefs use(block)
private lateinit var sshServiceApi: SshAuthenticationApi }
private var keyId private suspend fun prepare() {
get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) sshServiceApi =
set(value) { suspendCoroutine { cont ->
preferences.edit { sshServiceConnection.connect(
putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) 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) { override fun onError() {
prepare() throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
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() if (keyId == null) {
fetchPublicKey(true) selectKey()
}
check(keyId != null)
fetchPublicKey()
makePrivateKey()
}
private suspend fun fetchPublicKey(isRetry: Boolean = false) {
when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) {
is ApiResponse.Success -> {
val response = sshPublicKeyResponse.response as SshPublicKeyResponse
val sshPublicKey = response.sshPublicKey!!
publicKey =
parseSshPublicKey(sshPublicKey) ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
}
is ApiResponse.NoSuchKey ->
if (isRetry) {
throw sshPublicKeyResponse.exception
} else {
// Allow the user to reselect an authentication key and retry
selectKey()
fetchPublicKey(true)
}
is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception
}
}
private suspend fun selectKey() {
when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) {
is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId
is ApiResponse.GeneralError -> throw keySelectionResponse.exception
is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception
}
}
private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse {
d { "executeRequest($request) called" }
val result =
withContext(Dispatchers.Main) {
// If the request required user interaction, the data returned from the
// PendingIntent
// is used as the real request.
sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!!
}
return parseResult(request, result).also { d { "executeRequest($request): $it" } }
}
private suspend fun parseResult(request: Request, result: Intent): ApiResponse {
return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) {
SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
ApiResponse.Success(
when (request) {
is KeySelectionRequest -> KeySelectionResponse(result)
is SshPublicKeyRequest -> SshPublicKeyResponse(result)
is SigningRequest -> SigningResponse(result)
else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
}
)
}
SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
val resultOfUserInteraction: Intent =
withContext(Dispatchers.Main) {
suspendCoroutine { cont ->
activity.stashedCont = cont
activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build())
} }
is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception }
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 suspend fun selectKey() { private fun makePrivateKey() {
when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) { check(keyId != null && publicKey != null)
is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId privateKey =
is ApiResponse.GeneralError -> throw keySelectionResponse.exception object : OpenKeychainPrivateKey {
is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception 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()
}
private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse { override fun getPrivate() = privateKey
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 { override fun getPublic() = publicKey
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() { override fun getType(): KeyType = KeyType.fromKey(publicKey)
check(keyId != null && publicKey != null)
privateKey = object : OpenKeychainPrivateKey {
override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) {
is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
is ApiResponse.GeneralError -> throw signingResponse.exception
is ApiResponse.NoSuchKey -> throw signingResponse.exception
}
override fun getAlgorithm() = publicKey!!.algorithm
override fun getParams() = (publicKey as? ECKey)?.params
}
}
override fun close() {
activity.lifecycleScope.launch {
withContext(Dispatchers.Main) {
activity.continueAfterUserInteraction.unregister()
}
}
sshServiceConnection.disconnect()
}
override fun getPrivate() = privateKey
override fun getPublic() = publicKey
override fun getType(): KeyType = KeyType.fromKey(publicKey)
} }

View file

@ -8,8 +8,6 @@ import com.hierynomus.sshj.key.KeyAlgorithm
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.security.PrivateKey import java.security.PrivateKey
import java.security.interfaces.ECKey import java.security.interfaces.ECKey
import java.security.interfaces.ECPrivateKey
import java.security.spec.ECParameterSpec
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import net.schmizz.sshj.common.Buffer import net.schmizz.sshj.common.Buffer
import net.schmizz.sshj.common.Factory import net.schmizz.sshj.common.Factory
@ -18,79 +16,83 @@ import org.openintents.ssh.authentication.SshAuthenticationApi
interface OpenKeychainPrivateKey : PrivateKey, ECKey { 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 getFormat() = null
override fun getEncoded() = 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 { class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm {
private val hashAlgorithm = when (keyAlgorithm.keyAlgorithm) { private val hashAlgorithm =
"rsa-sha2-512" -> SshAuthenticationApi.SHA512 when (keyAlgorithm.keyAlgorithm) {
"rsa-sha2-256" -> SshAuthenticationApi.SHA256 "rsa-sha2-512" -> SshAuthenticationApi.SHA512
"ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1 "rsa-sha2-256" -> SshAuthenticationApi.SHA256
// Other algorithms don't use this value, but it has to be valid. "ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1
else -> SshAuthenticationApi.SHA512 // 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?) { override fun initSign(prvkey: PrivateKey?) {
if (prvkey is OpenKeychainPrivateKey) { if (prvkey is OpenKeychainPrivateKey) {
bridgedPrivateKey = prvkey bridgedPrivateKey = prvkey
} else {
wrappedSignature.initSign(prvkey)
}
}
override fun update(H: ByteArray?) {
if (bridgedPrivateKey != null) {
data.write(H!!)
} else {
wrappedSignature.update(H)
}
}
override fun update(H: ByteArray?, off: Int, len: Int) {
if (bridgedPrivateKey != null) {
data.write(H!!, off, len)
} else {
wrappedSignature.update(H, off, len)
}
}
override fun sign(): ByteArray? = if (bridgedPrivateKey != null) {
runBlocking {
bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm)
}
} else { } else {
wrappedSignature.sign() wrappedSignature.initSign(prvkey)
}
}
override fun update(H: ByteArray?) {
if (bridgedPrivateKey != null) {
data.write(H!!)
} else {
wrappedSignature.update(H)
}
}
override fun update(H: ByteArray?, off: Int, len: Int) {
if (bridgedPrivateKey != null) {
data.write(H!!, off, len)
} else {
wrappedSignature.update(H, off, len)
}
}
override fun sign(): ByteArray? =
if (bridgedPrivateKey != null) {
runBlocking { bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) }
} else {
wrappedSignature.sign()
} }
override fun encode(signature: ByteArray?): ByteArray? = if (bridgedPrivateKey != null) { override fun encode(signature: ByteArray?): ByteArray? =
require(signature != null) { "OpenKeychain signature must not be null" } if (bridgedPrivateKey != null) {
val encodedSignature = Buffer.PlainBuffer(signature) require(signature != null) { "OpenKeychain signature must not be null" }
// We need to drop the algorithm name and extract the raw signature since SSHJ adds the name val encodedSignature = Buffer.PlainBuffer(signature)
// later. // We need to drop the algorithm name and extract the raw signature since SSHJ adds the
encodedSignature.readString() // name
encodedSignature.readBytes().also { // later.
bridgedPrivateKey = null encodedSignature.readString()
data.reset() encodedSignature.readBytes().also {
} bridgedPrivateKey = null
data.reset()
}
} else { } else {
wrappedSignature.encode(signature) wrappedSignature.encode(signature)
} }
} }

View file

@ -51,286 +51,288 @@ private const val KEYSTORE_ALIAS = "sshkey"
private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs" private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs"
private val androidKeystore: KeyStore by lazy(LazyThreadSafetyMode.NONE) { 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 private val KeyStore.sshPrivateKey
get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
private val KeyStore.sshPublicKey private val KeyStore.sshPublicKey
get() = getCertificate(KEYSTORE_ALIAS)?.publicKey get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
fun parseSshPublicKey(sshPublicKey: String): PublicKey? { fun parseSshPublicKey(sshPublicKey: String): PublicKey? {
val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) val sshKeyParts = sshPublicKey.split("""\s+""".toRegex())
if (sshKeyParts.size < 2) if (sshKeyParts.size < 2) return null
return null return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
} }
fun toSshPublicKey(publicKey: PublicKey): String { fun toSshPublicKey(publicKey: PublicKey): String {
val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData
val keyType = KeyType.fromKey(publicKey) val keyType = KeyType.fromKey(publicKey)
return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}" return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}"
} }
object SshKey { object SshKey {
val sshPublicKey val sshPublicKey
get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null
val canShowSshPublicKey val canShowSshPublicKey
get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519) get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519)
val exists val exists
get() = type != null get() = type != null
val mustAuthenticate: Boolean val mustAuthenticate: Boolean
get() { get() {
return runCatching { return runCatching {
if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) return false
return false when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) { is PrivateKey -> {
is PrivateKey -> { val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired
return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired }
} is SecretKey -> {
is SecretKey -> { val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) (factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired
(factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired }
} else -> throw IllegalStateException("SSH key does not exist in Keystore")
else -> throw IllegalStateException("SSH key does not exist in Keystore")
}
}.getOrElse { error ->
// It is fine to swallow the exception here since it will reappear when the key is
// used for SSH authentication and can then be shown in the UI.
d(error)
false
}
} }
}
private val context: Context .getOrElse { error ->
get() = Application.instance.applicationContext // It is fine to swallow the exception here since it will reappear when the key
// is
private val privateKeyFile // used for SSH authentication and can then be shown in the UI.
get() = File(context.filesDir, ".ssh_key") d(error)
private val publicKeyFile false
get() = File(context.filesDir, ".ssh_key.pub")
private var type: Type?
get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
set(value) = context.sharedPrefs.edit {
putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value)
}
private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
else
false
}
private enum class Type(val value: String) {
Imported("imported"),
KeystoreNative("keystore_native"),
KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
// Behaves like `Imported`, but allows to view the public key.
LegacyGenerated("legacy_generated"),
;
companion object {
fun fromValue(value: String?): Type? = values().associateBy { it.value }[value]
} }
} }
enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) { private val context: Context
Rsa(KeyProperties.KEY_ALGORITHM_RSA, { get() = Application.instance.applicationContext
setKeySize(3072)
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) private val privateKeyFile
setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) get() = File(context.filesDir, ".ssh_key")
}), private val publicKeyFile
Ecdsa(KeyProperties.KEY_ALGORITHM_EC, { get() = File(context.filesDir, ".ssh_key.pub")
setKeySize(256)
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) private var type: Type?
setDigests(KeyProperties.DIGEST_SHA256) get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { set(value) = context.sharedPrefs.edit { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) }
setIsStrongBoxBacked(isStrongBoxSupported)
} private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) {
}), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
else false
}
private enum class Type(val value: String) {
Imported("imported"),
KeystoreNative("keystore_native"),
KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
// Behaves like `Imported`, but allows to view the public key.
LegacyGenerated("legacy_generated"),
;
companion object {
fun fromValue(value: String?): Type? = values().associateBy { it.value }[value]
}
}
enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) {
Rsa(
KeyProperties.KEY_ALGORITHM_RSA,
{
setKeySize(3072)
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
}
),
Ecdsa(
KeyProperties.KEY_ALGORITHM_EC,
{
setKeySize(256)
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
setDigests(KeyProperties.DIGEST_SHA256)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setIsStrongBoxBacked(isStrongBoxSupported)
}
}
),
}
private fun delete() {
androidKeystore.deleteEntry(KEYSTORE_ALIAS)
// Remove Tink key set used by AndroidX's EncryptedFile.
context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { clear() }
if (privateKeyFile.isFile) {
privateKeyFile.delete()
}
if (publicKeyFile.isFile) {
publicKeyFile.delete()
}
context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
type = null
}
fun import(uri: Uri) {
// First check whether the content at uri is likely an SSH private key.
val fileSize =
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
// Cursor returns only a single row.
cursor.moveToFirst()
cursor.getInt(0)
}
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
// We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
if (fileSize > 100_000 || fileSize == 0)
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
val sshKeyInputStream =
context.contentResolver.openInputStream(uri)
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
val lines = sshKeyInputStream.bufferedReader().readLines()
// The file must have more than 2 lines, and the first and last line must have private key
// markers.
if (lines.size < 2 ||
!Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
!Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
)
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
// At this point, we are reasonably confident that we have actually been provided a private
// key and delete the old key.
delete()
// Canonicalize line endings to '\n'.
privateKeyFile.writeText(lines.joinToString("\n"))
type = Type.Imported
}
@Deprecated("To be used only in Migrations.kt")
fun useLegacyKey(isGenerated: Boolean) {
type = if (isGenerated) Type.LegacyGenerated else Type.Imported
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) =
withContext(Dispatchers.IO) {
MasterKey.Builder(context, KEYSTORE_ALIAS).run {
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
setRequestStrongBoxBacked(true)
setUserAuthenticationRequired(requireAuthentication, 15)
build()
}
} }
private fun delete() { @Suppress("BlockingMethodInNonBlockingContext")
androidKeystore.deleteEntry(KEYSTORE_ALIAS) private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) =
// Remove Tink key set used by AndroidX's EncryptedFile. withContext(Dispatchers.IO) {
context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { EncryptedFile.Builder(
clear() context,
} privateKeyFile,
if (privateKeyFile.isFile) { getOrCreateWrappingMasterKey(requireAuthentication),
privateKeyFile.delete() EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
}
if (publicKeyFile.isFile) {
publicKeyFile.delete()
}
context.getEncryptedGitPrefs().edit {
remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
}
type = null
}
fun import(uri: Uri) {
// First check whether the content at uri is likely an SSH private key.
val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)
?.use { cursor ->
// Cursor returns only a single row.
cursor.moveToFirst()
cursor.getInt(0)
} ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
// We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
if (fileSize > 100_000 || fileSize == 0)
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
val sshKeyInputStream = context.contentResolver.openInputStream(uri)
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
val lines = sshKeyInputStream.bufferedReader().readLines()
// The file must have more than 2 lines, and the first and last line must have private key
// markers.
if (lines.size < 2 ||
!Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
!Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
) )
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) .run {
setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME)
// At this point, we are reasonably confident that we have actually been provided a private build()
// key and delete the old key.
delete()
// Canonicalize line endings to '\n'.
privateKeyFile.writeText(lines.joinToString("\n"))
type = Type.Imported
}
@Deprecated("To be used only in Migrations.kt")
fun useLegacyKey(isGenerated: Boolean) {
type = if (isGenerated) Type.LegacyGenerated else Type.Imported
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
MasterKey.Builder(context, KEYSTORE_ALIAS).run {
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
setRequestStrongBoxBacked(true)
setUserAuthenticationRequired(requireAuthentication, 15)
build()
} }
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) =
EncryptedFile.Builder(context, withContext(Dispatchers.IO) {
privateKeyFile, delete()
getOrCreateWrappingMasterKey(requireAuthentication),
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).run { val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication)
setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME) // Generate the ed25519 key pair and encrypt the private key.
build() 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
} }
@Suppress("BlockingMethodInNonBlockingContext") fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) {
suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { delete()
delete()
val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication) // Generate Keystore-backed private key.
// Generate the ed25519 key pair and encrypt the private key. val parameterSpec =
val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run {
encryptedPrivateKeyFile.openFileOutput().use { os -> apply(algorithm.applyToSpec)
os.write((keyPair.private as EdDSAPrivateKey).seed) 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. // Write public key in SSH format to .ssh_key.pub.
publicKeyFile.writeText(toSshPublicKey(keyPair.public)) publicKeyFile.writeText(toSshPublicKey(keyPair.public))
type = Type.KeystoreWrappedEd25519 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
} }
fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) { private object KeystoreNativeKeyProvider : KeyProvider {
delete()
// Generate Keystore-backed private key. override fun getPublic(): PublicKey =
val parameterSpec = KeyGenParameterSpec.Builder( runCatching { androidKeystore.sshPublicKey!! }.getOrElse { error ->
KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN e(error)
).run { throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error)
apply(algorithm.applyToSpec) }
if (requireAuthentication) {
setUserAuthenticationRequired(true) override fun getPrivate(): PrivateKey =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { runCatching { androidKeystore.sshPrivateKey!! }.getOrElse { error ->
setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL) e(error)
} else { throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error)
@Suppress("DEPRECATION") }
setUserAuthenticationValidityDurationSeconds(30)
} override fun getType(): KeyType = KeyType.fromKey(public)
} }
build()
} private object KeystoreWrappedEd25519KeyProvider : KeyProvider {
val keyPair = KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run {
initialize(parameterSpec) override fun getPublic(): PublicKey =
generateKeyPair() 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)
} }
// Write public key in SSH format to .ssh_key.pub. override fun getType(): KeyType = KeyType.fromKey(public)
publicKeyFile.writeText(toSshPublicKey(keyPair.public)) }
type = Type.KeystoreNative
}
fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = when (type) {
Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder)
Type.KeystoreNative -> KeystoreNativeKeyProvider
Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider
null -> null
}
private object KeystoreNativeKeyProvider : KeyProvider {
override fun getPublic(): PublicKey = runCatching {
androidKeystore.sshPublicKey!!
}.getOrElse { error ->
e(error)
throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error)
}
override fun getPrivate(): PrivateKey = runCatching {
androidKeystore.sshPrivateKey!!
}.getOrElse { error ->
e(error)
throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error)
}
override fun getType(): KeyType = KeyType.fromKey(public)
}
private object KeystoreWrappedEd25519KeyProvider : KeyProvider {
override fun getPublic(): PublicKey = runCatching {
parseSshPublicKey(sshPublicKey!!)!!
}.getOrElse { error ->
e(error)
throw IOException("Failed to get the public key for wrapped ed25519 key", error)
}
override fun getPrivate(): PrivateKey = runCatching {
// The current MasterKey API does not allow getting a reference to an existing one
// without specifying the KeySpec for a new one. However, the value for passed here
// for `requireAuthentication` is not used as the key already exists at this point.
val encryptedPrivateKeyFile = runBlocking {
getOrCreateWrappedPrivateKeyFile(false)
}
val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() }
EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC))
}.getOrElse { error ->
e(error)
throw IOException("Failed to unwrap wrapped ed25519 key", error)
}
override fun getType(): KeyType = KeyType.fromKey(public)
}
} }

View file

@ -33,250 +33,240 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.Marker import org.slf4j.Marker
fun setUpBouncyCastleForSshj() { fun setUpBouncyCastleForSshj() {
// Replace the Android BC provider with the Java BouncyCastle provider since the former does // Replace the Android BC provider with the Java BouncyCastle provider since the former does
// not include all the required algorithms. // not include all the required algorithms.
// Note: This may affect crypto operations in other parts of the application. // Note: This may affect crypto operations in other parts of the application.
val bcIndex = Security.getProviders().indexOfFirst { val bcIndex = Security.getProviders().indexOfFirst { it.name == BouncyCastleProvider.PROVIDER_NAME }
it.name == BouncyCastleProvider.PROVIDER_NAME if (bcIndex == -1) {
} // No Android BC found, install Java BC at lowest priority.
if (bcIndex == -1) { Security.addProvider(BouncyCastleProvider())
// No Android BC found, install Java BC at lowest priority. } else {
Security.addProvider(BouncyCastleProvider()) // Replace Android BC with Java BC, inserted at the same position.
} else { Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
// Replace Android BC with Java BC, inserted at the same position. // May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) runCatching { Class.forName("sun.security.jca.Providers") }
// May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261 Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
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.
d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } SecurityUtils.setRegisterBouncyCastle(false)
// Prevent sshj from forwarding all cryptographic operations to BC. SecurityUtils.setSecurityProvider(null)
SecurityUtils.setRegisterBouncyCastle(false)
SecurityUtils.setSecurityProvider(null)
} }
private abstract class AbstractLogger(private val name: String) : Logger { private abstract class AbstractLogger(private val name: String) : Logger {
abstract fun t(message: String, t: Throwable? = null, vararg args: Any?) abstract fun t(message: String, t: Throwable? = null, vararg args: Any?)
abstract fun d(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 i(message: String, t: Throwable? = null, vararg args: Any?)
abstract fun w(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?) abstract fun e(message: String, t: Throwable? = null, vararg args: Any?)
override fun getName() = name override fun getName() = name
override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled
override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled
override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled
override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled
override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled
override fun trace(msg: String) = t(msg) override fun trace(msg: String) = t(msg)
override fun trace(format: String, arg: Any?) = t(format, null, arg) 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, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2)
override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments) 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(msg: String, t: Throwable?) = t(msg, t)
override fun trace(marker: Marker, msg: String) = trace(msg) 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, arg: Any?) = trace(format, arg)
override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = trace(format, arg1, arg2)
trace(format, arg1, arg2)
override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = trace(format, *arguments)
trace(format, *arguments)
override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t) override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t)
override fun debug(msg: String) = d(msg) override fun debug(msg: String) = d(msg)
override fun debug(format: String, arg: Any?) = d(format, null, arg) 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, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2)
override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments) 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(msg: String, t: Throwable?) = d(msg, t)
override fun debug(marker: Marker, msg: String) = debug(msg) 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, arg: Any?) = debug(format, arg)
override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = debug(format, arg1, arg2)
debug(format, arg1, arg2)
override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = debug(format, *arguments)
debug(format, *arguments)
override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t) override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t)
override fun info(msg: String) = i(msg) override fun info(msg: String) = i(msg)
override fun info(format: String, arg: Any?) = i(format, null, arg) 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, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2)
override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments) 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(msg: String, t: Throwable?) = i(msg, t)
override fun info(marker: Marker, msg: String) = info(msg) 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, arg: Any?) = info(format, arg)
override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = info(format, arg1, arg2)
info(format, arg1, arg2)
override fun info(marker: Marker?, format: String, vararg arguments: Any?) = override fun info(marker: Marker?, format: String, vararg arguments: Any?) = info(format, *arguments)
info(format, *arguments)
override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t) override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t)
override fun warn(msg: String) = w(msg) override fun warn(msg: String) = w(msg)
override fun warn(format: String, arg: Any?) = w(format, null, arg) 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, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2)
override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments) 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(msg: String, t: Throwable?) = w(msg, t)
override fun warn(marker: Marker, msg: String) = warn(msg) 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, arg: Any?) = warn(format, arg)
override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = warn(format, arg1, arg2)
warn(format, arg1, arg2)
override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = warn(format, *arguments)
warn(format, *arguments)
override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t) override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t)
override fun error(msg: String) = e(msg) override fun error(msg: String) = e(msg)
override fun error(format: String, arg: Any?) = e(format, null, arg) 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, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2)
override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments) 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(msg: String, t: Throwable?) = e(msg, t)
override fun error(marker: Marker, msg: String) = error(msg) 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, arg: Any?) = error(format, arg)
override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = error(format, arg1, arg2)
error(format, arg1, arg2)
override fun error(marker: Marker?, format: String, vararg arguments: Any?) = override fun error(marker: Marker?, format: String, vararg arguments: Any?) = error(format, *arguments)
error(format, *arguments)
override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t) override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t)
} }
object TimberLoggerFactory : LoggerFactory { object TimberLoggerFactory : LoggerFactory {
private class TimberLogger(name: String) : AbstractLogger(name) { private class TimberLogger(name: String) : AbstractLogger(name) {
// We defer the log level checks to Timber. // We defer the log level checks to Timber.
override fun isTraceEnabled() = true override fun isTraceEnabled() = true
override fun isDebugEnabled() = true override fun isDebugEnabled() = true
override fun isInfoEnabled() = true override fun isInfoEnabled() = true
override fun isWarnEnabled() = true override fun isWarnEnabled() = true
override fun isErrorEnabled() = true override fun isErrorEnabled() = true
// Replace slf4j's "{}" format string style with standard Java's "%s". // Replace slf4j's "{}" format string style with standard Java's "%s".
// The supposedly redundant escape on the } is not redundant. // The supposedly redundant escape on the } is not redundant.
@Suppress("RegExpRedundantEscape") @Suppress("RegExpRedundantEscape") private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
override fun t(message: String, t: Throwable?, vararg args: Any?) { override fun t(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).v(t, message.fix(), *args) Timber.tag(name).v(t, message.fix(), *args)
}
override fun d(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).d(t, message.fix(), *args)
}
override fun i(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).i(t, message.fix(), *args)
}
override fun w(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).w(t, message.fix(), *args)
}
override fun e(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).e(t, message.fix(), *args)
}
} }
override fun getLogger(name: String): Logger { override fun d(message: String, t: Throwable?, vararg args: Any?) {
return TimberLogger(name) Timber.tag(name).d(t, message.fix(), *args)
} }
override fun getLogger(clazz: Class<*>): Logger { override fun i(message: String, t: Throwable?, vararg args: Any?) {
return TimberLogger(clazz.name) Timber.tag(name).i(t, message.fix(), *args)
} }
override fun w(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).w(t, message.fix(), *args)
}
override fun e(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).e(t, message.fix(), *args)
}
}
override fun getLogger(name: String): Logger {
return TimberLogger(name)
}
override fun getLogger(clazz: Class<*>): Logger {
return TimberLogger(clazz.name)
}
} }
class SshjConfig : ConfigImpl() { class SshjConfig : ConfigImpl() {
init { init {
loggerFactory = TimberLoggerFactory loggerFactory = TimberLoggerFactory
keepAliveProvider = KeepAliveProvider.HEARTBEAT keepAliveProvider = KeepAliveProvider.HEARTBEAT
version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1" version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1"
initKeyExchangeFactories() initKeyExchangeFactories()
initKeyAlgorithms() initKeyAlgorithms()
initRandomFactory() initRandomFactory()
initFileKeyProviderFactories() initFileKeyProviderFactories()
initCipherFactories() initCipherFactories()
initCompressionFactories() initCompressionFactories()
initMACFactories() initMACFactories()
} }
private fun initKeyExchangeFactories() { private fun initKeyExchangeFactories() {
keyExchangeFactories = listOf( keyExchangeFactories =
Curve25519SHA256.Factory(), listOf(
FactoryLibSsh(), Curve25519SHA256.Factory(),
ECDHNistP.Factory521(), FactoryLibSsh(),
ECDHNistP.Factory384(), ECDHNistP.Factory521(),
ECDHNistP.Factory256(), ECDHNistP.Factory384(),
DHGexSHA256.Factory(), ECDHNistP.Factory256(),
// Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get DHGexSHA256.Factory(),
// rsa-sha2-* key types to work with some servers (e.g. GitHub). // Sends "ext-info-c" with the list of key exchange algorithms. This is needed to
ExtInfoClientFactory(), // get
) // rsa-sha2-* key types to work with some servers (e.g. GitHub).
} ExtInfoClientFactory(),
)
}
private fun initKeyAlgorithms() { private fun initKeyAlgorithms() {
keyAlgorithms = listOf( keyAlgorithms =
KeyAlgorithms.SSHRSACertV01(), listOf(
KeyAlgorithms.EdDSA25519(), KeyAlgorithms.SSHRSACertV01(),
KeyAlgorithms.ECDSASHANistp521(), KeyAlgorithms.EdDSA25519(),
KeyAlgorithms.ECDSASHANistp384(), KeyAlgorithms.ECDSASHANistp521(),
KeyAlgorithms.ECDSASHANistp256(), KeyAlgorithms.ECDSASHANistp384(),
KeyAlgorithms.RSASHA512(), KeyAlgorithms.ECDSASHANistp256(),
KeyAlgorithms.RSASHA256(), KeyAlgorithms.RSASHA512(),
KeyAlgorithms.SSHRSA(), KeyAlgorithms.RSASHA256(),
).map { KeyAlgorithms.SSHRSA(),
OpenKeychainWrappedKeyAlgorithmFactory(it) )
} .map { OpenKeychainWrappedKeyAlgorithmFactory(it) }
} }
private fun initRandomFactory() { private fun initRandomFactory() {
randomFactory = SingletonRandomFactory(JCERandom.Factory()) randomFactory = SingletonRandomFactory(JCERandom.Factory())
} }
private fun initFileKeyProviderFactories() { private fun initFileKeyProviderFactories() {
fileKeyProviderFactories = listOf( fileKeyProviderFactories =
OpenSSHKeyV1KeyFile.Factory(), listOf(
PKCS8KeyFile.Factory(), OpenSSHKeyV1KeyFile.Factory(),
PKCS5KeyFile.Factory(), PKCS8KeyFile.Factory(),
OpenSSHKeyFile.Factory(), PKCS5KeyFile.Factory(),
PuTTYKeyFile.Factory(), OpenSSHKeyFile.Factory(),
) PuTTYKeyFile.Factory(),
} )
}
private fun initCipherFactories() {
cipherFactories =
listOf(
GcmCiphers.AES128GCM(),
GcmCiphers.AES256GCM(),
BlockCiphers.AES256CTR(),
BlockCiphers.AES192CTR(),
BlockCiphers.AES128CTR(),
)
}
private fun initCipherFactories() { private fun initMACFactories() {
cipherFactories = listOf( macFactories =
GcmCiphers.AES128GCM(), listOf(
GcmCiphers.AES256GCM(), Macs.HMACSHA2512Etm(),
BlockCiphers.AES256CTR(), Macs.HMACSHA2256Etm(),
BlockCiphers.AES192CTR(), Macs.HMACSHA2512(),
BlockCiphers.AES128CTR(), Macs.HMACSHA2256(),
) )
} }
private fun initMACFactories() { private fun initCompressionFactories() {
macFactories = listOf( compressionFactories =
Macs.HMACSHA2512Etm(), listOf(
Macs.HMACSHA2256Etm(), NoneCompression.Factory(),
Macs.HMACSHA2512(), )
Macs.HMACSHA2256(), }
)
}
private fun initCompressionFactories() {
compressionFactories = listOf(
NoneCompression.Factory(),
)
}
} }

View file

@ -40,158 +40,155 @@ import org.eclipse.jgit.transport.URIish
import org.eclipse.jgit.util.FS import org.eclipse.jgit.util.FS
sealed class SshAuthMethod(val activity: ContinuationContainerActivity) { sealed class SshAuthMethod(val activity: ContinuationContainerActivity) {
class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity) class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity) class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity) class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
} }
abstract class InteractivePasswordFinder : PasswordFinder { 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 { final override fun reqPassword(resource: Resource<*>?): CharArray {
val password = runBlocking(Dispatchers.Main) { val password = runBlocking(Dispatchers.Main) { suspendCoroutine<String?> { cont -> askForPassword(cont, isRetry) } }
suspendCoroutine<String?> { cont -> isRetry = true
askForPassword(cont, isRetry) return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
} }
}
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() { 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 { override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession {
return currentSession return currentSession
?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also { ?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
d { "New SSH connection created" } d { "New SSH connection created" }
currentSession = it currentSession = it
} }
} }
fun close() { fun close() {
currentSession?.close() currentSession?.close()
} }
} }
private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier { private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
if (!hostKeyFile.exists()) { if (!hostKeyFile.exists()) {
return HostKeyVerifier { _, _, key -> return HostKeyVerifier { _, _, key ->
val digest = runCatching { val digest =
SecurityUtils.getMessageDigest("SHA-256") runCatching { SecurityUtils.getMessageDigest("SHA-256") }.getOrElse { e -> throw SSHRuntimeException(e) }
}.getOrElse { e -> digest.update(PlainBuffer().putPublicKey(key).compactData)
throw SSHRuntimeException(e) val digestData = digest.digest()
} val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}"
digest.update(PlainBuffer().putPublicKey(key).compactData) d { "Trusting host key on first use: $hostKeyEntry" }
val digestData = digest.digest() hostKeyFile.writeText(hostKeyEntry)
val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}" true
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)
} }
} 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 class SshjSession(
uri: URIish,
private val username: String,
private val authMethod: SshAuthMethod,
private val hostKeyFile: File
) : RemoteSession {
private lateinit var ssh: SSHClient private lateinit var ssh: SSHClient
private var currentCommand: Session? = null private var currentCommand: Session? = null
private val uri = if (uri.host.contains('@')) { private val uri =
// URIish's String constructor cannot handle '@' in the user part of the URI and the URL if (uri.host.contains('@')) {
// constructor can't be used since Java's URL does not recognize the ssh scheme. We thus // URIish's String constructor cannot handle '@' in the user part of the URI and the URL
// need to patch everything up ourselves. // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus
d { "Before fixup: user=${uri.user}, host=${uri.host}" } // need to patch everything up ourselves.
val userPlusHost = "${uri.user}@${uri.host}" d { "Before fixup: user=${uri.user}, host=${uri.host}" }
val realUser = userPlusHost.substringBeforeLast('@') val userPlusHost = "${uri.user}@${uri.host}"
val realHost = userPlusHost.substringAfterLast('@') val realUser = userPlusHost.substringBeforeLast('@')
uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } } val realHost = userPlusHost.substringAfterLast('@')
uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } }
} else { } else {
uri uri
} }
fun connect(): SshjSession { fun connect(): SshjSession {
ssh = SSHClient(SshjConfig()) ssh = SSHClient(SshjConfig())
ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile)) ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile))
ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
if (!ssh.isConnected) if (!ssh.isConnected) throw IOException()
throw IOException() val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password))
val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password)) when (authMethod) {
when (authMethod) { is SshAuthMethod.Password -> {
is SshAuthMethod.Password -> { ssh.auth(username, passwordAuth)
ssh.auth(username, passwordAuth) }
} is SshAuthMethod.SshKey -> {
is SshAuthMethod.SshKey -> { val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey))) ssh.auth(username, pubkeyAuth, passwordAuth)
ssh.auth(username, pubkeyAuth, passwordAuth) }
} is SshAuthMethod.OpenKeychain -> {
is SshAuthMethod.OpenKeychain -> { runBlocking {
runBlocking { OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider ->
OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider -> val openKeychainAuth = AuthPublickey(provider)
val openKeychainAuth = AuthPublickey(provider) ssh.auth(username, openKeychainAuth, passwordAuth)
ssh.auth(username, openKeychainAuth, passwordAuth) }
}
}
}
} }
return this }
} }
return this
}
override fun exec(commandName: String?, timeout: Int): Process { override fun exec(commandName: String?, timeout: Int): Process {
if (currentCommand != null) { if (currentCommand != null) {
w { "Killing old command" } w { "Killing old command" }
disconnect() disconnect()
}
val session = ssh.startSession()
currentCommand = session
return SshjProcess(session.exec(commandName), timeout.toLong())
} }
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` * Kills the current command if one is running and returns the session into a state where `exec`
* can be called. * can be called.
* *
* Note that this does *not* disconnect the session. Unfortunately, the function has to be * Note that this does *not* disconnect the session. Unfortunately, the function has to be called
* called `disconnect` to override the corresponding abstract function in `RemoteSession`. * `disconnect` to override the corresponding abstract function in `RemoteSession`.
*/ */
override fun disconnect() { override fun disconnect() {
currentCommand?.close() currentCommand?.close()
currentCommand = null currentCommand = null
} }
fun close() { fun close() {
disconnect() disconnect()
ssh.close() ssh.close()
} }
} }
private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() { private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() {
override fun waitFor(): Int { override fun waitFor(): Int {
command.join(timeout, TimeUnit.SECONDS) command.join(timeout, TimeUnit.SECONDS)
command.close() command.close()
return exitValue() 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
} }

View file

@ -15,52 +15,52 @@ import java.net.ProxySelector
import java.net.SocketAddress import java.net.SocketAddress
import java.net.URI import java.net.URI
/** /** Utility class for [Proxy] handling. */
* Utility class for [Proxy] handling.
*/
object ProxyUtils { object ProxyUtils {
private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser" private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser"
private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword" private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword"
/** /** Set the default [Proxy] and [Authenticator] for the app based on user provided settings. */
* Set the default [Proxy] and [Authenticator] for the app based on user provided settings. fun setDefaultProxy() {
*/ ProxySelector.setDefault(
fun setDefaultProxy() { object : ProxySelector() {
ProxySelector.setDefault(object : ProxySelector() { override fun select(uri: URI?): MutableList<Proxy> {
override fun select(uri: URI?): MutableList<Proxy> { val host = GitSettings.proxyHost
val host = GitSettings.proxyHost val port = GitSettings.proxyPort
val port = GitSettings.proxyPort return if (host == null || port == -1) {
return if (host == null || port == -1) { mutableListOf(Proxy.NO_PROXY)
mutableListOf(Proxy.NO_PROXY) } else {
} else { mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port)))
mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port))) }
}
}
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
if (uri == null || sa == null || ioe == null) {
throw IllegalArgumentException("Arguments can't be null.")
}
}
})
val user = GitSettings.proxyUsername ?: ""
val password = GitSettings.proxyPassword ?: ""
if (user.isEmpty() || password.isEmpty()) {
System.clearProperty(HTTP_PROXY_USER_PROPERTY)
System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
} else {
System.setProperty(HTTP_PROXY_USER_PROPERTY, user)
System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password)
} }
Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? { override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
return if (requestorType == RequestorType.PROXY) { if (uri == null || sa == null || ioe == null) {
PasswordAuthentication(user, password.toCharArray()) throw IllegalArgumentException("Arguments can't be null.")
} 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
}
}
}
)
}
} }

View file

@ -12,128 +12,118 @@ import dev.msfjarvis.aps.util.extensions.hasFlag
import dev.msfjarvis.aps.util.settings.PreferenceKeys import dev.msfjarvis.aps.util.settings.PreferenceKeys
enum class PasswordOption(val key: String) { enum class PasswordOption(val key: String) {
NoDigits("0"), NoDigits("0"),
NoUppercaseLetters("A"), NoUppercaseLetters("A"),
NoAmbiguousCharacters("B"), NoAmbiguousCharacters("B"),
FullyRandom("s"), FullyRandom("s"),
AtLeastOneSymbol("y"), AtLeastOneSymbol("y"),
NoLowercaseLetters("L") NoLowercaseLetters("L")
} }
object PasswordGenerator { object PasswordGenerator {
const val DEFAULT_LENGTH = 16 const val DEFAULT_LENGTH = 16
const val DIGITS = 0x0001 const val DIGITS = 0x0001
const val UPPERS = 0x0002 const val UPPERS = 0x0002
const val SYMBOLS = 0x0004 const val SYMBOLS = 0x0004
const val NO_AMBIGUOUS = 0x0008 const val NO_AMBIGUOUS = 0x0008
const val LOWERS = 0x0020 const val LOWERS = 0x0020
const val DIGITS_STR = "0123456789" const val DIGITS_STR = "0123456789"
const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz" const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz"
const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2" const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2"
/** /**
* Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for * Enables the [PasswordOption] s in [options] and sets [targetLength] as the length for generated
* generated passwords. * passwords.
*/ */
fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean { fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean {
ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
for (possibleOption in PasswordOption.values()) for (possibleOption in PasswordOption.values()) putBoolean(possibleOption.key, possibleOption in options)
putBoolean(possibleOption.key, possibleOption in options) putInt("length", targetLength)
putInt("length", targetLength) }
return true
}
fun isValidPassword(password: String, pwFlags: Int): Boolean {
if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) return false
if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) return false
if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) return false
if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) return false
if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) return false
return true
}
/** Generates a password using the preferences set by [setPrefs]. */
@Throws(PasswordGeneratorException::class)
fun generate(ctx: Context): String {
val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
var numCharacterCategories = 0
var phonemes = true
var pwgenFlags = DIGITS or UPPERS or LOWERS
for (option in PasswordOption.values()) {
if (prefs.getBoolean(option.key, false)) {
when (option) {
PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS)
PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS)
PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS)
PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS
PasswordOption.FullyRandom -> phonemes = false
PasswordOption.AtLeastOneSymbol -> {
numCharacterCategories++
pwgenFlags = pwgenFlags or SYMBOLS
}
} }
return true } 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 -> {}
}
}
} }
fun isValidPassword(password: String, pwFlags: Int): Boolean { val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH)
if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) {
return false throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error))
if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) }
return false if (length < numCharacterCategories) {
if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error))
return false }
if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) {
return false phonemes = false
if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS)
return false }
return true // 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?
* Generates a password using the preferences set by [setPrefs]. var iterations = 0
*/ do {
@Throws(PasswordGeneratorException::class) if (iterations++ > 1000)
fun generate(ctx: Context): String { throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded))
val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) password =
var numCharacterCategories = 0 if (phonemes) {
RandomPhonemesGenerator.generate(length, pwgenFlags)
var phonemes = true } else {
var pwgenFlags = DIGITS or UPPERS or LOWERS RandomPasswordGenerator.generate(length, pwgenFlags)
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 -> {
}
}
}
} }
} while (password == null)
return password
}
val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH) class PasswordGeneratorException(string: String) : Exception(string)
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)
} }

View file

@ -8,26 +8,24 @@ import java.security.SecureRandom
private val secureRandom = 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) 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() fun secureRandomBoolean() = secureRandom.nextBoolean()
/** /**
* Returns `true` with probability [percentTrue]% and `false` with probability * Returns `true` with probability [percentTrue]% and `false` with probability `(100 - [percentTrue]
* `(100 - [percentTrue])`%. * )`%.
*/ */
fun secureRandomBiasedBoolean(percentTrue: Int): Boolean { fun secureRandomBiasedBoolean(percentTrue: Int): Boolean {
require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" } require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" }
require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" } require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" }
return secureRandomNumber(100) < percentTrue return secureRandomNumber(100) < percentTrue
} }
fun <T> Array<T>.secureRandomElement() = this[secureRandomNumber(size)] fun <T> Array<T>.secureRandomElement() = this[secureRandomNumber(size)]
fun <T> List<T>.secureRandomElement() = this[secureRandomNumber(size)] fun <T> List<T>.secureRandomElement() = this[secureRandomNumber(size)]
fun String.secureRandomCharacter() = this[secureRandomNumber(length)] fun String.secureRandomCharacter() = this[secureRandomNumber(length)]

View file

@ -8,38 +8,39 @@ import dev.msfjarvis.aps.util.extensions.hasFlag
object RandomPasswordGenerator { object RandomPasswordGenerator {
/** /**
* Generates a random password of length [targetLength], taking the following flags in [pwFlags] * Generates a random password of length [targetLength], taking the following flags in [pwFlags]
* into account, or fails to do so and returns null: * into account, or fails to do so and returns null:
* *
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set,
* set, the password will not contain any digits. * the password will not contain any digits.
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter;
* letter; if not set, the password will not contain any uppercase letters. * if not set, the password will not contain any uppercase letters.
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter;
* letter; if not set, the password will not contain any lowercase letters. * 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 * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
* set, the password will not contain any symbols. * set, the password will not contain any symbols.
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
* characters. * characters.
*/ */
fun generate(targetLength: Int, pwFlags: Int): String? { fun generate(targetLength: Int, pwFlags: Int): String? {
val bank = listOfNotNull( val bank =
PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS }, listOfNotNull(
PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS }, PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS },
PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS }, PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS },
PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS }, PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS },
).joinToString("") PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS },
)
.joinToString("")
var password = "" var password = ""
while (password.length < targetLength) { while (password.length < targetLength) {
val candidate = bank.secureRandomCharacter() val candidate = bank.secureRandomCharacter()
if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate in PasswordGenerator.AMBIGUOUS_STR) {
candidate in PasswordGenerator.AMBIGUOUS_STR) { continue
continue }
} password += candidate
password += candidate
}
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
} }
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
}
} }

View file

@ -9,161 +9,161 @@ import java.util.Locale
object RandomPhonemesGenerator { object RandomPhonemesGenerator {
private const val CONSONANT = 0x0001 private const val CONSONANT = 0x0001
private const val VOWEL = 0x0002 private const val VOWEL = 0x0002
private const val DIPHTHONG = 0x0004 private const val DIPHTHONG = 0x0004
private const val NOT_FIRST = 0x0008 private const val NOT_FIRST = 0x0008
private val elements = arrayOf( private val elements =
Element("a", VOWEL), arrayOf(
Element("ae", VOWEL or DIPHTHONG), Element("a", VOWEL),
Element("ah", VOWEL or DIPHTHONG), Element("ae", VOWEL or DIPHTHONG),
Element("ai", VOWEL or DIPHTHONG), Element("ah", VOWEL or DIPHTHONG),
Element("b", CONSONANT), Element("ai", VOWEL or DIPHTHONG),
Element("c", CONSONANT), Element("b", CONSONANT),
Element("ch", CONSONANT or DIPHTHONG), Element("c", CONSONANT),
Element("d", CONSONANT), Element("ch", CONSONANT or DIPHTHONG),
Element("e", VOWEL), Element("d", CONSONANT),
Element("ee", VOWEL or DIPHTHONG), Element("e", VOWEL),
Element("ei", VOWEL or DIPHTHONG), Element("ee", VOWEL or DIPHTHONG),
Element("f", CONSONANT), Element("ei", VOWEL or DIPHTHONG),
Element("g", CONSONANT), Element("f", CONSONANT),
Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST), Element("g", CONSONANT),
Element("h", CONSONANT), Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST),
Element("i", VOWEL), Element("h", CONSONANT),
Element("ie", VOWEL or DIPHTHONG), Element("i", VOWEL),
Element("j", CONSONANT), Element("ie", VOWEL or DIPHTHONG),
Element("k", CONSONANT), Element("j", CONSONANT),
Element("l", CONSONANT), Element("k", CONSONANT),
Element("m", CONSONANT), Element("l", CONSONANT),
Element("n", CONSONANT), Element("m", CONSONANT),
Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST), Element("n", CONSONANT),
Element("o", VOWEL), Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST),
Element("oh", VOWEL or DIPHTHONG), Element("o", VOWEL),
Element("oo", VOWEL or DIPHTHONG), Element("oh", VOWEL or DIPHTHONG),
Element("p", CONSONANT), Element("oo", VOWEL or DIPHTHONG),
Element("ph", CONSONANT or DIPHTHONG), Element("p", CONSONANT),
Element("qu", CONSONANT or DIPHTHONG), Element("ph", CONSONANT or DIPHTHONG),
Element("r", CONSONANT), Element("qu", CONSONANT or DIPHTHONG),
Element("s", CONSONANT), Element("r", CONSONANT),
Element("sh", CONSONANT or DIPHTHONG), Element("s", CONSONANT),
Element("t", CONSONANT), Element("sh", CONSONANT or DIPHTHONG),
Element("th", CONSONANT or DIPHTHONG), Element("t", CONSONANT),
Element("u", VOWEL), Element("th", CONSONANT or DIPHTHONG),
Element("v", CONSONANT), Element("u", VOWEL),
Element("w", CONSONANT), Element("v", CONSONANT),
Element("x", CONSONANT), Element("w", CONSONANT),
Element("y", CONSONANT), Element("x", CONSONANT),
Element("z", CONSONANT) Element("y", CONSONANT),
Element("z", CONSONANT)
) )
private class Element(str: String, val flags: Int) { private class Element(str: String, val flags: Int) {
val upperCase = str.toUpperCase(Locale.ROOT) val upperCase = str.toUpperCase(Locale.ROOT)
val lowerCase = str.toLowerCase(Locale.ROOT) val lowerCase = str.toLowerCase(Locale.ROOT)
val length = str.length val length = str.length
val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR } val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR }
} }
/** /**
* Generates a random human-readable password of length [targetLength], taking the following * Generates a random human-readable password of length [targetLength], taking the following flags
* flags in [pwFlags] into account, or fails to do so and returns null: * 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 * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set,
* set, the password will not contain any digits. * the password will not contain any digits.
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter;
* letter; if not set, the password will not contain any uppercase letters. * if not set, the password will not contain any uppercase letters.
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter;
* letter; if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any * if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any lowercase
* lowercase characters; if both are not set, an exception is thrown. * characters; if both are not set, an exception is thrown.
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
* set, the password will not contain any symbols. * set, the password will not contain any symbols.
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
* characters. * characters.
*/ */
fun generate(targetLength: Int, pwFlags: Int): String? { fun generate(targetLength: Int, pwFlags: Int): String? {
require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS) require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS)
var password = "" var password = ""
var isStartOfPart = true var isStartOfPart = true
var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
var previousFlags = 0 var previousFlags = 0
while (password.length < targetLength) { while (password.length < targetLength) {
// First part: Add a single letter or pronounceable pair of letters in varying case. // First part: Add a single letter or pronounceable pair of letters in varying case.
val candidate = elements.secureRandomElement() val candidate = elements.secureRandomElement()
// Reroll if the candidate does not fulfill the current requirements. // Reroll if the candidate does not fulfill the current requirements.
if (!candidate.flags.hasFlag(nextBasicType) || if (!candidate.flags.hasFlag(nextBasicType) ||
(isStartOfPart && candidate.flags hasFlag NOT_FIRST) || (isStartOfPart && candidate.flags hasFlag NOT_FIRST) ||
// Don't let a diphthong that starts with a vowel follow a vowel. // Don't let a diphthong that starts with a vowel follow a vowel.
(previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) || (previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) ||
// Don't add multi-character candidates if we would go over the targetLength. // Don't add multi-character candidates if we would go over the targetLength.
(password.length + candidate.length > targetLength) || (password.length + candidate.length > targetLength) ||
(pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)) { (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)
continue ) {
} continue
}
// At this point the candidate could be appended to the password, but we still have // 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 // to determine the case. If no upper case characters are required, we don't add
// any. // any.
val useUpperIfBothCasesAllowed = val useUpperIfBothCasesAllowed =
(isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20) (isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20)
password += if (pwFlags hasFlag PasswordGenerator.UPPERS && password +=
(!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)) { if (pwFlags hasFlag PasswordGenerator.UPPERS &&
candidate.upperCase (!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)
} else { ) {
candidate.lowerCase candidate.upperCase
} } else {
candidate.lowerCase
// We ensured above that we will not go above the target length.
check(password.length <= targetLength)
if (password.length == targetLength)
break
// Second part: Add digits and symbols with a certain probability (if requested) if
// they would not directly follow the first character in a pronounceable part.
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS &&
secureRandomBiasedBoolean(30)) {
var randomDigit: Char
do {
randomDigit = secureRandomNumber(10).toString(10).first()
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
randomDigit in PasswordGenerator.AMBIGUOUS_STR)
password += randomDigit
// Begin a new pronounceable part after every digit.
isStartOfPart = true
nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
previousFlags = 0
continue
}
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS &&
secureRandomBiasedBoolean(20)) {
var randomSymbol: Char
do {
randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter()
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
randomSymbol in PasswordGenerator.AMBIGUOUS_STR)
password += randomSymbol
// Continue the password generation as if nothing was added.
}
// Third part: Determine the basic type of the next character depending on the letter
// we just added.
nextBasicType = when {
candidate.flags.hasFlag(CONSONANT) -> VOWEL
previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) ||
secureRandomBiasedBoolean(60) -> CONSONANT
else -> VOWEL
}
previousFlags = candidate.flags
isStartOfPart = false
} }
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
// We ensured above that we will not go above the target length.
check(password.length <= targetLength)
if (password.length == targetLength) break
// Second part: Add digits and symbols with a certain probability (if requested) if
// they would not directly follow the first character in a pronounceable part.
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS && secureRandomBiasedBoolean(30)) {
var randomDigit: Char
do {
randomDigit = secureRandomNumber(10).toString(10).first()
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && randomDigit in PasswordGenerator.AMBIGUOUS_STR)
password += randomDigit
// Begin a new pronounceable part after every digit.
isStartOfPart = true
nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
previousFlags = 0
continue
}
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS && secureRandomBiasedBoolean(20)) {
var randomSymbol: Char
do {
randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter()
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && randomSymbol in PasswordGenerator.AMBIGUOUS_STR)
password += randomSymbol
// Continue the password generation as if nothing was added.
}
// Third part: Determine the basic type of the next character depending on the letter
// we just added.
nextBasicType =
when {
candidate.flags.hasFlag(CONSONANT) -> VOWEL
previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) || secureRandomBiasedBoolean(60) ->
CONSONANT
else -> VOWEL
}
previousFlags = candidate.flags
isStartOfPart = false
} }
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
}
} }

View file

@ -5,5 +5,9 @@
package dev.msfjarvis.aps.util.pwgenxkpwd package dev.msfjarvis.aps.util.pwgenxkpwd
enum class CapsType { enum class CapsType {
lowercase, UPPERCASE, TitleCase, Sentence, As_iS lowercase,
UPPERCASE,
TitleCase,
Sentence,
As_iS
} }

View file

@ -16,127 +16,120 @@ import java.util.Locale
class PasswordBuilder(ctx: Context) { class PasswordBuilder(ctx: Context) {
private var numSymbols = 0 private var numSymbols = 0
private var isAppendSymbolsSeparator = false private var isAppendSymbolsSeparator = false
private var context = ctx private var context = ctx
private var numWords = 3 private var numWords = 3
private var maxWordLength = 9 private var maxWordLength = 9
private var minWordLength = 5 private var minWordLength = 5
private var separator = "." private var separator = "."
private var capsType = CapsType.Sentence private var capsType = CapsType.Sentence
private var prependDigits = 0 private var prependDigits = 0
private var numDigits = 0 private var numDigits = 0
private var isPrependWithSeparator = false private var isPrependWithSeparator = false
private var isAppendNumberSeparator = false private var isAppendNumberSeparator = false
fun setNumberOfWords(amount: Int) = apply { fun setNumberOfWords(amount: Int) = apply { numWords = amount }
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 { private fun generateRandomSymbolSequence(numSymbols: Int): String {
minWordLength = min val numbers = StringBuilder(numSymbols)
for (i in 0 until numSymbols) {
numbers.append(SYMBOLS.secureRandomCharacter())
} }
return numbers.toString()
}
fun setMaximumWordLength(max: Int) = apply { @OptIn(ExperimentalStdlibApi::class)
maxWordLength = max 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())
}
fun setSeparator(separator: String) = apply { if (wordBank.size == 0) {
this.separator = separator throw PasswordGeneratorException(
} context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength)
)
}
fun setCapitalization(capitalizationScheme: CapsType) = apply { for (i in 0 until numWords) {
capsType = capitalizationScheme val candidate = wordBank.secureRandomElement()
} val s =
when (capsType) {
@JvmOverloads CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault())
fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply { CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate
prependDigits = numDigits CapsType.TitleCase -> candidate.capitalize(Locale.getDefault())
isPrependWithSeparator = addSeparator CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault())
} CapsType.As_iS -> candidate
}
@JvmOverloads password.append(s)
fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply { if (i + 1 < numWords) {
this.numDigits = numDigits password.append(separator)
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() }
} if (numDigits != 0) {
if (isAppendNumberSeparator) {
private fun generateRandomSymbolSequence(numSymbols: Int): String { password.append(separator)
val numbers = StringBuilder(numSymbols)
for (i in 0 until numSymbols) {
numbers.append(SYMBOLS.secureRandomCharacter())
} }
return numbers.toString() password.append(generateRandomNumberSequence(numDigits))
} }
if (numSymbols != 0) {
@OptIn(ExperimentalStdlibApi::class) if (isAppendSymbolsSeparator) {
fun create(): Result<String, Throwable> { password.append(separator)
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(generateRandomSymbolSequence(numSymbols))
}
password.toString()
} }
}
companion object { companion object {
private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#" private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#"
} }
} }

View file

@ -13,28 +13,28 @@ import java.io.File
class XkpwdDictionary(context: Context) { class XkpwdDictionary(context: Context) {
val words: Map<Int, List<String>> val words: Map<Int, List<String>>
init { init {
val prefs = context.sharedPrefs val prefs = context.sharedPrefs
val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: "" val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: ""
val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE) val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE)
val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) && val lines =
uri.isNotEmpty() && customDictFile.canRead()) { if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) &&
customDictFile.readLines() uri.isNotEmpty() &&
} else { customDictFile.canRead()
context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines() ) {
} customDictFile.readLines()
} else {
context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines()
}
words = lines.asSequence() words = lines.asSequence().map { it.trim() }.filter { it.isNotEmpty() && !it.contains(' ') }.groupBy { it.length }
.map { it.trim() } }
.filter { it.isNotEmpty() && !it.contains(' ') }
.groupBy { it.length }
}
companion object { companion object {
const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt" const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt"
} }
} }

View file

@ -32,155 +32,150 @@ import kotlinx.coroutines.withContext
class ClipboardService : Service() { class ClipboardService : Service() {
private val scope = CoroutineScope(Job() + Dispatchers.Main) private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) { if (intent != null) {
when (intent.action) { when (intent.action) {
ACTION_CLEAR -> { ACTION_CLEAR -> {
clearClipboard() clearClipboard()
stopForeground(true) stopForeground(true)
stopSelf() stopSelf()
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
ACTION_START -> {
val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45)
ACTION_START -> { if (time == 0) {
val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45) stopSelf()
}
if (time == 0) { createNotification(time)
stopSelf() scope.launch {
} withContext(Dispatchers.IO) { startTimer(time) }
withContext(Dispatchers.Main) {
createNotification(time) clearClipboard()
scope.launch { stopForeground(true)
withContext(Dispatchers.IO) { stopSelf()
startTimer(time)
}
withContext(Dispatchers.Main) {
clearClipboard()
stopForeground(true)
stopSelf()
}
}
return START_NOT_STICKY
}
} }
}
return START_NOT_STICKY
} }
}
return super.onStartCommand(intent, flags, startId)
} }
override fun onBind(intent: Intent?): IBinder? { return super.onStartCommand(intent, flags, startId)
return null }
}
override fun onDestroy() { override fun onBind(intent: Intent?): IBinder? {
scope.cancel() return null
super.onDestroy() }
}
private fun clearClipboard() { override fun onDestroy() {
val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false) scope.cancel()
val clipboard = clipboard super.onDestroy()
}
if (clipboard != null) { private fun clearClipboard() {
scope.launch { val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false)
d { "Clearing the clipboard" } val clipboard = clipboard
val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
clipboard.setPrimaryClip(clip) if (clipboard != null) {
if (deepClear) { scope.launch {
withContext(Dispatchers.IO) { d { "Clearing the clipboard" }
repeat(CLIPBOARD_CLEAR_COUNT) { val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
val count = (it * 500).toString() clipboard.setPrimaryClip(clip)
clipboard.setPrimaryClip(ClipData.newPlainText(count, count)) 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" }
} }
}
} else {
d { "Cannot get clipboard manager service" }
} }
}
private suspend fun startTimer(showTime: Int) { private suspend fun startTimer(showTime: Int) {
var current = 0 var current = 0
while (scope.isActive && current < showTime) { while (scope.isActive && current < showTime) {
// Block for 1s or until cancel is signalled // Block for 1s or until cancel is signalled
current++ current++
delay(1000) delay(1000)
}
} }
}
private fun createNotification(clearTime: Int) { private fun createNotification(clearTime: Int) {
val clearTimeMs = clearTime * 1000L val clearTimeMs = clearTime * 1000L
val clearIntent = Intent(this, ClipboardService::class.java).apply { val clearIntent = Intent(this, ClipboardService::class.java).apply { action = ACTION_CLEAR }
action = ACTION_CLEAR val pendingIntent =
} if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) } else {
} else { PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) }
} val notification =
val notification = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
createNotificationApi23(pendingIntent) createNotificationApi23(pendingIntent)
} else { } else {
createNotificationApi24(pendingIntent, clearTimeMs) createNotificationApi24(pendingIntent, clearTimeMs)
} }
createNotificationChannel() createNotificationChannel()
startForeground(1, notification) 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" }
}
} }
}
private fun createNotificationApi23(pendingIntent: PendingIntent): Notification { companion object {
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) const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER"
private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification { const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME"
return NotificationCompat.Builder(this, CHANNEL_ID) private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
.setContentTitle(getString(R.string.app_name)) private const val CHANNEL_ID = "NotificationService"
.setContentText(getString(R.string.tap_clear_clipboard)) // Newest Samsung phones now feature a history of up to 30 items. To err on the side of
.setSmallIcon(R.drawable.ic_action_secure_24dp) // caution,
.setContentIntent(pendingIntent) // push 35 fake ones.
.setUsesChronometer(true) private const val CLIPBOARD_CLEAR_COUNT = 35
.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
}
} }

View file

@ -38,108 +38,121 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
class OreoAutofillService : AutofillService() { class OreoAutofillService : AutofillService() {
companion object { companion object {
// TODO: Provide a user-configurable denylist // TODO: Provide a user-configurable denylist
private val DENYLISTED_PACKAGES = listOf( private val DENYLISTED_PACKAGES =
BuildConfig.APPLICATION_ID, listOf(
"android", BuildConfig.APPLICATION_ID,
"com.android.settings", "android",
"com.android.settings.intelligence", "com.android.settings",
"com.android.systemui", "com.android.settings.intelligence",
"com.oneplus.applocker", "com.android.systemui",
"org.sufficientlysecure.keychain", "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() { override fun onCreate() {
super.onCreate() super.onCreate()
cachePublicSuffixList(applicationContext) cachePublicSuffixList(applicationContext)
} }
override fun onFillRequest( override fun onFillRequest(request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback) {
request: FillRequest, val structure =
cancellationSignal: CancellationSignal, request.fillContexts.lastOrNull()?.structure
callback: FillCallback ?: run {
) { callback.onSuccess(null)
val structure = request.fillContexts.lastOrNull()?.structure ?: run { return
callback.onSuccess(null)
return
}
if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
if (Build.VERSION.SDK_INT >= 28) {
callback.onSuccess(FillResponse.Builder().run {
disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
build()
})
} else {
callback.onSuccess(null)
}
return
}
val formToFill = FillableForm.parseAssistStructure(
this, structure,
isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST,
getCustomSuffixes(),
) ?: run {
d { "Form cannot be filled" }
callback.onSuccess(null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback)
} else {
AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
}
}
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
// SaveCallback's behavior and feature set differs based on both target and device SDK, so
// we replace it with a wrapper that works the same in all situations.
@Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback)
val structure = request.fillContexts.lastOrNull()?.structure ?: run {
callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported))
return
}
val clientState = request.clientState ?: run {
e { "Received save request without client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val scenario = AutofillScenario.fromClientState(clientState)?.recoverNodes(structure)
?: run {
e { "Failed to recover client state or nodes from client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val formOrigin = FormOrigin.fromBundle(clientState) ?: run {
e { "Failed to recover form origin from client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val username = scenario.usernameValue
val password = scenario.passwordValue ?: run {
callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match))
return
} }
if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
if (Build.VERSION.SDK_INT >= 28) {
callback.onSuccess( callback.onSuccess(
AutofillSaveActivity.makeSaveIntentSender( FillResponse.Builder().run {
this, disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
credentials = Credentials(username, password, null), build()
formOrigin = formOrigin }
)
) )
} else {
callback.onSuccess(null)
}
return
} }
val formToFill =
FillableForm.parseAssistStructure(
this,
structure,
isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST,
getCustomSuffixes(),
)
?: run {
d { "Form cannot be filled" }
callback.onSuccess(null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback)
} else {
AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
}
}
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
// SaveCallback's behavior and feature set differs based on both target and device SDK, so
// we replace it with a wrapper that works the same in all situations.
@Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback)
val structure =
request.fillContexts.lastOrNull()?.structure
?: run {
callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported))
return
}
val clientState =
request.clientState
?: run {
e { "Received save request without client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val scenario =
AutofillScenario.fromClientState(clientState)?.recoverNodes(structure)
?: run {
e { "Failed to recover client state or nodes from client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val formOrigin =
FormOrigin.fromBundle(clientState)
?: run {
e { "Failed to recover form origin from client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val username = scenario.usernameValue
val password =
scenario.passwordValue
?: run {
callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match))
return
}
callback.onSuccess(
AutofillSaveActivity.makeSaveIntentSender(
this,
credentials = Credentials(username, password, null),
formOrigin = formOrigin
)
)
}
} }
fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
fun Context.getCustomSuffixes(): Sequence<String> { fun Context.getCustomSuffixes(): Sequence<String> {
return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)?.splitToSequence('\n')?.filter {
?.splitToSequence('\n') it.isNotBlank() && it.first() != '.' && it.last() != '.'
?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' } }
?: emptySequence() ?: emptySequence()
} }

View file

@ -25,134 +25,131 @@ import java.util.TimeZone
class PasswordExportService : Service() { class PasswordExportService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) { if (intent != null) {
when (intent.action) { when (intent.action) {
ACTION_EXPORT_PASSWORD -> { ACTION_EXPORT_PASSWORD -> {
val uri = intent.getParcelableExtra<Uri>("uri") val uri = intent.getParcelableExtra<Uri>("uri")
if (uri != null) { if (uri != null) {
val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri) val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
if (targetDirectory != null) { if (targetDirectory != null) {
createNotification() createNotification()
exportPasswords(targetDirectory) exportPasswords(targetDirectory)
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
}
}
}
} }
}
} }
return super.onStartCommand(intent, flags, startId) }
} }
return super.onStartCommand(intent, flags, startId)
}
override fun onBind(intent: Intent?): IBinder? { override fun onBind(intent: Intent?): IBinder? {
return null 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. * Copies a password file to a given directory.
* *
* Recursively copies the existing password store to an external directory. * Note: this does not preserve last modified time.
* *
* @param targetDirectory directory to copy password directory to. * @param passwordFile password file to copy.
*/ * @param targetDirectory target directory to copy password.
private fun exportPasswords(targetDirectory: DocumentFile) { */
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)
val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory()) if (destOutputStream != null && sourceInputStream != null) {
val sourcePassDir = DocumentFile.fromFile(repositoryDirectory) sourceInputStream.copyTo(destOutputStream, 1024)
d { "Copying ${repositoryDirectory.path} to $targetDirectory" } sourceInputStream.close()
destOutputStream.close()
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. * Recursively copies a directory to a destination.
* *
* Note: this does not preserve last modified time. * @param sourceDirectory directory to copy from.
* * @param targetDirectory directory to copy to.
* @param passwordFile password file to copy. */
* @param targetDirectory target directory to copy password. private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) {
*/ sourceDirectory.listFiles().forEach { file ->
private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) { if (file.isDirectory) {
val sourceInputStream = contentResolver.openInputStream(passwordFile.uri) // Create new directory and recurse
val name = passwordFile.name val newDir = targetDirectory.createDirectory(file.name!!)
val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!) copyDirToDir(file, newDir!!)
if (targetPasswordFile?.exists() == true) { } else {
val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri) copyFileToDir(file, targetDirectory)
}
if (destOutputStream != null && sourceInputStream != null) {
sourceInputStream.copyTo(destOutputStream, 1024)
sourceInputStream.close()
destOutputStream.close()
}
}
} }
}
/** private fun createNotification() {
* Recursively copies a directory to a destination. createNotificationChannel()
*
* @param sourceDirectory directory to copy from. val notification =
* @param targetDirectory directory to copy to. NotificationCompat.Builder(this, CHANNEL_ID)
*/ .setContentTitle(getString(R.string.app_name))
private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) { .setContentText(getString(R.string.exporting_passwords))
sourceDirectory.listFiles().forEach { file -> .setSmallIcon(R.drawable.ic_round_import_export)
if (file.isDirectory) { .setPriority(NotificationCompat.PRIORITY_LOW)
// Create new directory and recurse .build()
val newDir = targetDirectory.createDirectory(file.name!!)
copyDirToDir(file, newDir!!) startForeground(2, notification)
} else { }
copyFileToDir(file, targetDirectory)
} 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 createNotification() { companion object {
createNotificationChannel()
val notification = NotificationCompat.Builder(this, CHANNEL_ID) const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD"
.setContentTitle(getString(R.string.app_name)) private const val CHANNEL_ID = "NotificationService"
.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" }
}
}
}
companion object {
const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD"
private const val CHANNEL_ID = "NotificationService"
}
} }

View file

@ -17,191 +17,168 @@ import java.io.File
import org.eclipse.jgit.transport.URIish import org.eclipse.jgit.transport.URIish
enum class Protocol(val pref: String) { enum class Protocol(val pref: String) {
Ssh("ssh://"), Ssh("ssh://"),
Https("https://"), Https("https://"),
; ;
companion object { companion object {
private val map = values().associateBy(Protocol::pref) private val map = values().associateBy(Protocol::pref)
fun fromString(type: String?): Protocol { fun fromString(type: String?): Protocol {
return map[type ?: return Ssh] return map[type ?: return Ssh] ?: throw IllegalArgumentException("$type is not a valid Protocol")
?: throw IllegalArgumentException("$type is not a valid Protocol")
}
} }
}
} }
enum class AuthMode(val pref: String) { enum class AuthMode(val pref: String) {
SshKey("ssh-key"), SshKey("ssh-key"),
Password("username/password"), Password("username/password"),
OpenKeychain("OpenKeychain"), OpenKeychain("OpenKeychain"),
None("None"), None("None"),
; ;
companion object { companion object {
private val map = values().associateBy(AuthMode::pref) private val map = values().associateBy(AuthMode::pref)
fun fromString(type: String?): AuthMode { fun fromString(type: String?): AuthMode {
return map[type ?: return SshKey] return map[type ?: return SshKey] ?: throw IllegalArgumentException("$type is not a valid AuthMode")
?: throw IllegalArgumentException("$type is not a valid AuthMode")
}
} }
}
} }
object GitSettings { object GitSettings {
private const val DEFAULT_BRANCH = "master" private const val DEFAULT_BRANCH = "master"
private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs } private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs }
private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedGitPrefs() } private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) {
private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() } Application.instance.getEncryptedGitPrefs()
private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" } }
private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() }
private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" }
var authMode var authMode
get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH)) get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
private set(value) { private set(value) {
settings.edit { settings.edit { putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) }
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()
} }
fun updateConnectionSettingsIfValid(newAuthMode: AuthMode, newUrl: String, newBranch: String): UpdateConnectionSettingsResult { var url
val parsedUrl = runCatching { get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL)
URIish(newUrl) private set(value) {
}.getOrElse { require(value != null)
return UpdateConnectionSettingsResult.FailedToParseUrl if (value == url) return
} settings.edit { putString(PreferenceKeys.GIT_REMOTE_URL, value) }
val newProtocol = when (parsedUrl.scheme) { if (PasswordRepository.isInitialized) PasswordRepository.addRemote("origin", value, true)
in listOf("http", "https") -> Protocol.Https // When the server changes, remote password, multiplexing support and host key file
in listOf("ssh", null) -> Protocol.Ssh // should be deleted/reset.
else -> return UpdateConnectionSettingsResult.FailedToParseUrl useMultiplexing = true
} encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank()) clearSavedHostKey()
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 authorName
* Deletes a previously saved SSH host key get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
*/ set(value) {
fun clearSavedHostKey() { settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) }
File(hostKeyPath).delete()
} }
/** var authorEmail
* Returns true if a host key was previously saved get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: ""
*/ set(value) {
fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists() 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()
}
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()
} }

View file

@ -20,108 +20,100 @@ import java.io.File
import java.net.URI import java.net.URI
fun runMigrations(context: Context) { fun runMigrations(context: Context) {
val sharedPrefs = context.sharedPrefs val sharedPrefs = context.sharedPrefs
migrateToGitUrlBasedConfig(sharedPrefs) migrateToGitUrlBasedConfig(sharedPrefs)
migrateToHideAll(sharedPrefs) migrateToHideAll(sharedPrefs)
migrateToSshKey(context, sharedPrefs) migrateToSshKey(context, sharedPrefs)
migrateToClipboardHistory(sharedPrefs) migrateToClipboardHistory(sharedPrefs)
} }
private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) { private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) {
val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) ?: return
?: return i { "Migrating to URL-based Git config" }
i { "Migrating to URL-based Git config" } val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: ""
val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: "" val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: ""
val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: "" val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: ""
val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: "" val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
// Whether we need the leading ssh:// depends on the use of a custom port. // Whether we need the leading ssh:// depends on the use of a custom port.
val hostnamePart = serverHostname.removePrefix("ssh://") val hostnamePart = serverHostname.removePrefix("ssh://")
val url = when (protocol) { val url =
Protocol.Ssh -> { when (protocol) {
val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@" Protocol.Ssh -> {
val portPart = val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@"
if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort" val portPart = if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort"
if (portPart.isEmpty()) { if (portPart.isEmpty()) {
"$userPart$hostnamePart:$serverPath" "$userPart$hostnamePart:$serverPath"
} else { } else {
// Only absolute paths are supported with custom ports. // Only absolute paths are supported with custom ports.
if (!serverPath.startsWith('/')) if (!serverPath.startsWith('/')) null
null else
else // We have to specify the ssh scheme as this is the only way to pass a custom
// We have to specify the ssh scheme as this is the only way to pass a custom // port.
// port. "ssh://$userPart$hostnamePart$portPart$serverPath"
"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()
} }
}
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 { sharedPrefs.edit {
remove(PreferenceKeys.GIT_REMOTE_LOCATION) remove(PreferenceKeys.GIT_REMOTE_LOCATION)
remove(PreferenceKeys.GIT_REMOTE_PORT) remove(PreferenceKeys.GIT_REMOTE_PORT)
remove(PreferenceKeys.GIT_REMOTE_SERVER) remove(PreferenceKeys.GIT_REMOTE_SERVER)
remove(PreferenceKeys.GIT_REMOTE_USERNAME) remove(PreferenceKeys.GIT_REMOTE_USERNAME)
remove(PreferenceKeys.GIT_REMOTE_PROTOCOL) remove(PreferenceKeys.GIT_REMOTE_PROTOCOL)
} }
if (url == null || GitSettings.updateConnectionSettingsIfValid( if (url == null ||
newAuthMode = GitSettings.authMode, GitSettings.updateConnectionSettingsIfValid(
newUrl = url, newAuthMode = GitSettings.authMode,
newBranch = GitSettings.branch) != GitSettings.UpdateConnectionSettingsResult.Valid) { newUrl = url,
e { "Failed to migrate to URL-based Git config, generated URL is invalid" } newBranch = GitSettings.branch
} ) != GitSettings.UpdateConnectionSettingsResult.Valid
) {
e { "Failed to migrate to URL-based Git config, generated URL is invalid" }
}
} }
private fun migrateToHideAll(sharedPrefs: SharedPreferences) { private fun migrateToHideAll(sharedPrefs: SharedPreferences) {
sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return
val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false) val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
sharedPrefs.edit { sharedPrefs.edit {
remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS) remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS)
putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden) putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden)
} }
} }
private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) { private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) {
val privateKeyFile = File(context.filesDir, ".ssh_key") val privateKeyFile = File(context.filesDir, ".ssh_key")
if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && !SshKey.exists && privateKeyFile.exists()) {
!SshKey.exists && // Currently uses a private key imported or generated with an old version of Password Store.
privateKeyFile.exists()) { // Generated keys come with a public key which the user should still be able to view after
// Currently uses a private key imported or generated with an old version of Password Store. // the migration (not possible for regular imported keys), hence the special case.
// Generated keys come with a public key which the user should still be able to view after val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
// the migration (not possible for regular imported keys), hence the special case. SshKey.useLegacyKey(isGeneratedKey)
val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false) sharedPrefs.edit { remove(PreferenceKeys.USE_GENERATED_KEY) }
SshKey.useLegacyKey(isGeneratedKey) }
sharedPrefs.edit {
remove(PreferenceKeys.USE_GENERATED_KEY)
}
}
} }
private fun migrateToClipboardHistory(sharedPrefs: SharedPreferences) { private fun migrateToClipboardHistory(sharedPrefs: SharedPreferences) {
if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) { if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) {
sharedPrefs.edit { sharedPrefs.edit {
putBoolean( putBoolean(
PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, PreferenceKeys.CLEAR_CLIPBOARD_HISTORY,
sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false) sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
) )
remove(PreferenceKeys.CLEAR_CLIPBOARD_20X) remove(PreferenceKeys.CLEAR_CLIPBOARD_20X)
}
} }
}
} }

View file

@ -13,37 +13,36 @@ import dev.msfjarvis.aps.util.extensions.base64
import dev.msfjarvis.aps.util.extensions.getString import dev.msfjarvis.aps.util.extensions.getString
enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) { enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) {
FOLDER_FIRST(
FOLDER_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem -> Comparator { p1: PasswordItem, p2: PasswordItem ->
(p1.type + p1.name) (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true)
.compareTo(p2.type + p2.name, ignoreCase = true)
}),
INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem ->
p1.name.compareTo(p2.name, ignoreCase = true)
}),
RECENTLY_USED(Comparator { p1: PasswordItem, p2: PasswordItem ->
val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
when {
timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1)
timeP1 != null && timeP2 == null -> return@Comparator -1
timeP1 == null && timeP2 != null -> return@Comparator 1
else -> p1.name.compareTo(p2.name, ignoreCase = true)
}
}),
FILE_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem ->
(p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
});
companion object {
@JvmStatic
fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name)
}
} }
),
INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem -> p1.name.compareTo(p2.name, ignoreCase = true) }),
RECENTLY_USED(
Comparator { p1: PasswordItem, p2: PasswordItem ->
val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
when {
timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1)
timeP1 != null && timeP2 == null -> return@Comparator -1
timeP1 == null && timeP2 != null -> return@Comparator 1
else -> p1.name.compareTo(p2.name, ignoreCase = true)
}
}
),
FILE_FIRST(
Comparator { p1: PasswordItem, p2: PasswordItem ->
(p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
}
);
companion object {
@JvmStatic
fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name)
}
}
} }

View file

@ -7,85 +7,79 @@ package dev.msfjarvis.aps.util.settings
object PreferenceKeys { object PreferenceKeys {
const val APP_THEME = "app_theme" const val APP_THEME = "app_theme"
const val APP_VERSION = "app_version" const val APP_VERSION = "app_version"
const val AUTOFILL_ENABLE = "autofill_enable" const val AUTOFILL_ENABLE = "autofill_enable"
const val BIOMETRIC_AUTH = "biometric_auth" const val BIOMETRIC_AUTH = "biometric_auth"
@Deprecated( @Deprecated(
message = "Use CLEAR_CLIPBOARD_HISTORY instead", message = "Use CLEAR_CLIPBOARD_HISTORY instead",
replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"), replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"),
) )
const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x" const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x"
const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history" const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history"
const val CLEAR_SAVED_PASS = "clear_saved_pass" const val CLEAR_SAVED_PASS = "clear_saved_pass"
const val COPY_ON_DECRYPT = "copy_on_decrypt" const val COPY_ON_DECRYPT = "copy_on_decrypt"
const val ENABLE_DEBUG_LOGGING = "enable_debug_logging" const val ENABLE_DEBUG_LOGGING = "enable_debug_logging"
const val EXPORT_PASSWORDS = "export_passwords" const val EXPORT_PASSWORDS = "export_passwords"
const val FILTER_RECURSIVELY = "filter_recursively" const val FILTER_RECURSIVELY = "filter_recursively"
const val GENERAL_SHOW_TIME = "general_show_time" const val GENERAL_SHOW_TIME = "general_show_time"
const val GIT_CONFIG = "git_config" const val GIT_CONFIG = "git_config"
const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email" const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email"
const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name" const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name"
const val GIT_EXTERNAL = "git_external" const val GIT_EXTERNAL = "git_external"
const val GIT_EXTERNAL_REPO = "git_external_repo" const val GIT_EXTERNAL_REPO = "git_external_repo"
const val GIT_REMOTE_AUTH = "git_remote_auth" const val GIT_REMOTE_AUTH = "git_remote_auth"
const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type" const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type"
@Deprecated("Use GIT_REMOTE_URL instead") @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_LOCATION = "git_remote_location"
const val GIT_REMOTE_LOCATION = "git_remote_location" const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing"
const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing"
@Deprecated("Use GIT_REMOTE_URL instead") @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PORT = "git_remote_port"
const val GIT_REMOTE_PORT = "git_remote_port"
@Deprecated("Use GIT_REMOTE_URL instead") @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PROTOCOL = "git_remote_protocol"
const val GIT_REMOTE_PROTOCOL = "git_remote_protocol" const val GIT_DELETE_REPO = "git_delete_repo"
const val GIT_DELETE_REPO = "git_delete_repo"
@Deprecated("Use GIT_REMOTE_URL instead") @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_SERVER = "git_remote_server"
const val GIT_REMOTE_SERVER = "git_remote_server" const val GIT_REMOTE_URL = "git_remote_url"
const val GIT_REMOTE_URL = "git_remote_url"
@Deprecated("Use GIT_REMOTE_URL instead") @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_USERNAME = "git_remote_username"
const val GIT_REMOTE_USERNAME = "git_remote_username" const val GIT_SERVER_INFO = "git_server_info"
const val GIT_SERVER_INFO = "git_server_info" const val GIT_BRANCH_NAME = "git_branch"
const val GIT_BRANCH_NAME = "git_branch" const val HTTPS_PASSWORD = "https_password"
const val HTTPS_PASSWORD = "https_password" const val LENGTH = "length"
const val LENGTH = "length" const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes"
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_DEFAULT_USERNAME = "oreo_autofill_default_username" const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure"
const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure" const val PREF_KEY_CUSTOM_DICT = "pref_key_custom_dict"
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_IS_CUSTOM_DICT = "pref_key_is_custom_dict" const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type"
const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type" const val PREF_SELECT_EXTERNAL = "pref_select_external"
const val PREF_SELECT_EXTERNAL = "pref_select_external" const val REPOSITORY_INITIALIZED = "repository_initialized"
const val REPOSITORY_INITIALIZED = "repository_initialized" const val REPO_CHANGED = "repo_changed"
const val REPO_CHANGED = "repo_changed" const val SEARCH_ON_START = "search_on_start"
const val SEARCH_ON_START = "search_on_start"
@Deprecated( @Deprecated(
message = "Use SHOW_HIDDEN_CONTENTS instead", message = "Use SHOW_HIDDEN_CONTENTS instead",
replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS") replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS")
) )
const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders" const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders"
const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents" const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents"
const val SORT_ORDER = "sort_order" const val SORT_ORDER = "sort_order"
const val SHOW_PASSWORD = "show_password" const val SHOW_PASSWORD = "show_password"
const val SSH_KEY = "ssh_key" const val SSH_KEY = "ssh_key"
const val SSH_KEYGEN = "ssh_keygen" const val SSH_KEYGEN = "ssh_keygen"
const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase" 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_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid"
const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid" const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid"
const val SSH_SEE_KEY = "ssh_see_key" const val SSH_SEE_KEY = "ssh_see_key"
@Deprecated("To be used only in Migrations.kt") @Deprecated("To be used only in Migrations.kt") const val USE_GENERATED_KEY = "use_generated_key"
const val USE_GENERATED_KEY = "use_generated_key"
const val PROXY_SETTINGS = "proxy_settings" const val PROXY_SETTINGS = "proxy_settings"
const val PROXY_HOST = "proxy_host" const val PROXY_HOST = "proxy_host"
const val PROXY_PORT = "proxy_port" const val PROXY_PORT = "proxy_port"
const val PROXY_USERNAME = "proxy_username" const val PROXY_USERNAME = "proxy_username"
const val PROXY_PASSWORD = "proxy_password" const val PROXY_PASSWORD = "proxy_password"
const val REBASE_ON_PULL = "rebase_on_pull" const val REBASE_ON_PULL = "rebase_on_pull"
} }

Some files were not shown because too many files have changed in this diff Show more