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

View file

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

View file

@ -18,98 +18,105 @@ import org.junit.Test
class PasswordEntryAndroidTest {
private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder())
private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder())
@Test fun testGetPassword() {
assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
assertEquals("fooooo", makeEntry("fooooo\nbla").password)
assertEquals("fooooo", makeEntry("fooooo\n").password)
assertEquals("fooooo", makeEntry("fooooo").password)
assertEquals("", makeEntry("\nblubb\n").password)
assertEquals("", makeEntry("\nblubb").password)
assertEquals("", makeEntry("\n").password)
assertEquals("", makeEntry("").password)
@Test
fun testGetPassword() {
assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
assertEquals("fooooo", makeEntry("fooooo\nbla").password)
assertEquals("fooooo", makeEntry("fooooo\n").password)
assertEquals("fooooo", makeEntry("fooooo").password)
assertEquals("", makeEntry("\nblubb\n").password)
assertEquals("", makeEntry("\nblubb").password)
assertEquals("", makeEntry("\n").password)
assertEquals("", makeEntry("").password)
}
@Test
fun testGetExtraContent() {
assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent)
assertEquals("bla", makeEntry("fooooo\nbla").extraContent)
assertEquals("", makeEntry("fooooo\n").extraContent)
assertEquals("", makeEntry("fooooo").extraContent)
assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent)
assertEquals("blubb", makeEntry("\nblubb").extraContent)
assertEquals("", makeEntry("\n").extraContent)
assertEquals("", makeEntry("").extraContent)
}
@Test
fun 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() {
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 testHasUsername() {
assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername())
assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername())
assertFalse(makeEntry("secret\nlogin failed\n").hasUsername())
assertFalse(makeEntry("\n").hasUsername())
assertFalse(makeEntry("").hasUsername())
}
@Test fun 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 testGeneratesOtpFromTotpUri() {
val entry = makeEntry("secret\nextra\n$TOTP_URI")
assertTrue(entry.hasTotp())
val code =
Otp.calculateCode(
entry.totpSecret!!,
// The hardcoded date value allows this test to stay reproducible.
Date(8640000).time / (1000 * entry.totpPeriod),
entry.totpAlgorithm,
entry.digits
)
.get()
assertNotNull(code) { "Generated OTP cannot be null" }
assertEquals(entry.digits.toInt(), code.length)
assertEquals("545293", code)
}
@Test fun testHasUsername() {
assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername())
assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername())
assertFalse(makeEntry("secret\nlogin failed\n").hasUsername())
assertFalse(makeEntry("\n").hasUsername())
assertFalse(makeEntry("").hasUsername())
}
@Test
fun testGeneratesOtpWithOnlyUriInFile() {
val entry = makeEntry(TOTP_URI)
assertTrue(entry.password.isEmpty())
assertTrue(entry.hasTotp())
val code =
Otp.calculateCode(
entry.totpSecret!!,
// The hardcoded date value allows this test to stay reproducible.
Date(8640000).time / (1000 * entry.totpPeriod),
entry.totpAlgorithm,
entry.digits
)
.get()
assertNotNull(code) { "Generated OTP cannot be null" }
assertEquals(entry.digits.toInt(), code.length)
assertEquals("545293", code)
}
@Test fun testGeneratesOtpFromTotpUri() {
val entry = makeEntry("secret\nextra\n$TOTP_URI")
assertTrue(entry.hasTotp())
val code = Otp.calculateCode(
entry.totpSecret!!,
// The hardcoded date value allows this test to stay reproducible.
Date(8640000).time / (1000 * entry.totpPeriod),
entry.totpAlgorithm,
entry.digits
).get()
assertNotNull(code) { "Generated OTP cannot be null" }
assertEquals(entry.digits.toInt(), code.length)
assertEquals("545293", code)
}
@Test
fun testOnlyLooksForUriInFirstLine() {
val entry = makeEntry("id:\n$TOTP_URI")
assertTrue(entry.password.isNotEmpty())
assertTrue(entry.hasTotp())
assertFalse(entry.hasUsername())
}
@Test fun testGeneratesOtpWithOnlyUriInFile() {
val entry = makeEntry(TOTP_URI)
assertTrue(entry.password.isEmpty())
assertTrue(entry.hasTotp())
val code = Otp.calculateCode(
entry.totpSecret!!,
// The hardcoded date value allows this test to stay reproducible.
Date(8640000).time / (1000 * entry.totpPeriod),
entry.totpAlgorithm,
entry.digits
).get()
assertNotNull(code) { "Generated OTP cannot be null" }
assertEquals(entry.digits.toInt(), code.length)
assertEquals("545293", code)
}
companion object {
@Test fun testOnlyLooksForUriInFirstLine() {
val entry = makeEntry("id:\n$TOTP_URI")
assertTrue(entry.password.isNotEmpty())
assertTrue(entry.hasTotp())
assertFalse(entry.hasUsername())
}
companion object {
const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
}
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 {
private fun checkOldKeysAreRemoved(context: Context) = with(context.sharedPrefs) {
assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT))
assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME))
assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER))
assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION))
assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
private fun checkOldKeysAreRemoved(context: Context) =
with(context.sharedPrefs) {
assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT))
assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME))
assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER))
assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION))
assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
}
@Test
fun verifySshWithCustomPortMigration() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit {
clear()
putString(PreferenceKeys.GIT_REMOTE_PORT, "2200")
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.Password.pref)
}
runMigrations(context)
checkOldKeysAreRemoved(context)
assertEquals(
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
"ssh://msfjarvis@192.168.0.102:2200/mnt/disk3/pass-repo"
)
@Test
fun verifySshWithCustomPortMigration() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit {
clear()
putString(PreferenceKeys.GIT_REMOTE_PORT, "2200")
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.Password.pref)
}
runMigrations(context)
checkOldKeysAreRemoved(context)
assertEquals(
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
"ssh://msfjarvis@192.168.0.102:2200/mnt/disk3/pass-repo"
)
}
@Test
fun verifySshWithDefaultPortMigration() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit {
clear()
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.SshKey.pref)
}
runMigrations(context)
checkOldKeysAreRemoved(context)
assertEquals(
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
"msfjarvis@192.168.0.102:/mnt/disk3/pass-repo"
)
@Test
fun verifySshWithDefaultPortMigration() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit {
clear()
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.SshKey.pref)
}
runMigrations(context)
checkOldKeysAreRemoved(context)
assertEquals(
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
"msfjarvis@192.168.0.102:/mnt/disk3/pass-repo"
)
}
@Test
fun verifyHttpsWithGitHubMigration() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit {
clear()
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "Android-Password-Store/pass-test")
putString(PreferenceKeys.GIT_REMOTE_SERVER, "github.com")
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Https.pref)
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.None.pref)
}
runMigrations(context)
checkOldKeysAreRemoved(context)
assertEquals(
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
"https://github.com/Android-Password-Store/pass-test"
)
@Test
fun verifyHttpsWithGitHubMigration() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit {
clear()
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "Android-Password-Store/pass-test")
putString(PreferenceKeys.GIT_REMOTE_SERVER, "github.com")
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Https.pref)
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.None.pref)
}
runMigrations(context)
checkOldKeysAreRemoved(context)
assertEquals(
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
"https://github.com/Android-Password-Store/pass-test"
)
}
@Test
fun verifyHiddenFoldersMigrationIfDisabled() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit { clear() }
runMigrations(context)
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true))
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
}
@Test
fun verifyHiddenFoldersMigrationIfDisabled() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit { clear() }
runMigrations(context)
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true))
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
}
@Test
fun verifyHiddenFoldersMigrationIfEnabled() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit {
clear()
putBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true)
}
runMigrations(context)
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false))
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
@Test
fun verifyHiddenFoldersMigrationIfEnabled() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit {
clear()
putBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true)
}
runMigrations(context)
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false))
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
}
@Test
fun verifyClearClipboardHistoryMigration() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit {
clear()
putBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, true)
}
runMigrations(context)
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false))
assertFalse(context.sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X))
@Test
fun verifyClearClipboardHistoryMigration() {
val context = Application.instance.applicationContext
context.sharedPrefs.edit {
clear()
putBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, true)
}
runMigrations(context)
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false))
assertFalse(context.sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X))
}
}

View file

@ -10,36 +10,40 @@ import org.junit.Test
class UriTotpFinderTest {
private val totpFinder = UriTotpFinder()
private val totpFinder = UriTotpFinder()
@Test
fun findSecret() {
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI))
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"))
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT))
}
@Test
fun findSecret() {
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI))
assertEquals(
"HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ",
totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")
)
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT))
}
@Test
fun findDigits() {
assertEquals("12", totpFinder.findDigits(TOTP_URI))
assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT))
}
@Test
fun findDigits() {
assertEquals("12", totpFinder.findDigits(TOTP_URI))
assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT))
}
@Test
fun findPeriod() {
assertEquals(25, totpFinder.findPeriod(TOTP_URI))
assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT))
}
@Test
fun findPeriod() {
assertEquals(25, totpFinder.findPeriod(TOTP_URI))
assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT))
}
@Test
fun findAlgorithm() {
assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI))
assertEquals("SHA256", totpFinder.findAlgorithm(PASS_FILE_CONTENT))
}
@Test
fun findAlgorithm() {
assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI))
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 PASS_FILE_CONTENT = "password\n$TOTP_URI"
}
const val TOTP_URI =
"otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25"
const val PASS_FILE_CONTENT = "password\n$TOTP_URI"
}
}

View file

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

View file

@ -14,14 +14,14 @@ import androidx.appcompat.app.AppCompatActivity
@Suppress("UNUSED_PARAMETER")
class AutofillSmsActivity : AppCompatActivity() {
companion object {
companion object {
fun shouldOfferFillFromSms(context: Context): Boolean {
return false
}
fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender {
throw NotImplementedError("Filling OTPs from SMS requires non-free dependencies")
}
fun shouldOfferFillFromSms(context: Context): Boolean {
return false
}
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")
class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener {
private val prefs by lazy { sharedPrefs }
private val prefs by lazy { sharedPrefs }
override fun onCreate() {
super.onCreate()
instance = this
if (BuildConfig.ENABLE_DEBUG_FEATURES ||
prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) {
plant(DebugTree())
}
prefs.registerOnSharedPreferenceChangeListener(this)
setNightMode()
setUpBouncyCastleForSshj()
runMigrations(applicationContext)
ProxyUtils.setDefaultProxy()
override fun onCreate() {
super.onCreate()
instance = this
if (BuildConfig.ENABLE_DEBUG_FEATURES || prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) {
plant(DebugTree())
}
prefs.registerOnSharedPreferenceChangeListener(this)
setNightMode()
setUpBouncyCastleForSshj()
runMigrations(applicationContext)
ProxyUtils.setDefaultProxy()
}
override fun onTerminate() {
prefs.unregisterOnSharedPreferenceChangeListener(this)
super.onTerminate()
override fun onTerminate() {
prefs.unregisterOnSharedPreferenceChangeListener(this)
super.onTerminate()
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) {
if (key == PreferenceKeys.APP_THEME) {
setNightMode()
}
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) {
if (key == PreferenceKeys.APP_THEME) {
setNightMode()
}
}
private fun setNightMode() {
AppCompatDelegate.setDefaultNightMode(
when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) {
"light" -> MODE_NIGHT_NO
"dark" -> MODE_NIGHT_YES
"follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM
else -> MODE_NIGHT_AUTO_BATTERY
}
)
}
private fun setNightMode() {
AppCompatDelegate.setDefaultNightMode(when (prefs.getString(PreferenceKeys.APP_THEME)
?: getString(R.string.app_theme_def)) {
"light" -> MODE_NIGHT_NO
"dark" -> MODE_NIGHT_YES
"follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM
else -> MODE_NIGHT_AUTO_BATTERY
})
}
companion object {
companion object {
lateinit var instance: Application
}
lateinit var instance: Application
}
}

View file

@ -6,27 +6,30 @@
package dev.msfjarvis.aps.data.password
class FieldItem(val key: String, val value: String, val action: ActionType) {
enum class ActionType {
COPY, HIDE
enum class ActionType {
COPY,
HIDE
}
enum class ItemType(val type: String) {
USERNAME("Username"),
PASSWORD("Password"),
OTP("OTP")
}
companion object {
// Extra helper methods
fun createOtpField(otp: String): FieldItem {
return FieldItem(ItemType.OTP.type, otp, ActionType.COPY)
}
enum class ItemType(val type: String) {
USERNAME("Username"), PASSWORD("Password"), OTP("OTP")
fun createPasswordField(password: String): FieldItem {
return FieldItem(ItemType.PASSWORD.type, password, ActionType.HIDE)
}
companion object {
// 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)
}
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()) {
val password: String
val username: String?
val password: String
val username: String?
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val digits: String
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpSecret: String?
val totpPeriod: Long
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpAlgorithm: String
val extraContent: String
val extraContentWithoutAuthData: String
val extraContentMap: Map<String, String>
constructor(os: ByteArrayOutputStream) : this(os.toString(Charsets.UTF_8.name()), UriTotpFinder())
init {
val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
password = foundPassword
extraContent = passContent.joinToString("\n")
extraContentWithoutAuthData = generateExtraContentWithoutAuthData()
extraContentMap = generateExtraContentPairs()
username = findUsername()
digits = findOtpDigits(content)
totpSecret = findTotpSecret(content)
totpPeriod = findTotpPeriod(content)
totpAlgorithm = findTotpAlgorithm(content)
}
fun hasExtraContent(): Boolean {
return extraContent.isNotEmpty()
}
fun hasExtraContentWithoutAuthData(): Boolean {
return extraContentWithoutAuthData.isNotEmpty()
}
fun hasTotp(): Boolean {
return totpSecret != null
}
fun hasUsername(): Boolean {
return username != null
}
fun calculateTotpCode(): String? {
if (totpSecret == null) return null
return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get()
}
private fun generateExtraContentWithoutAuthData(): String {
var foundUsername = false
return extraContent
.lineSequence()
.filter { line ->
return@filter when {
USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> {
foundUsername = true
false
}
line.startsWith("otpauth://", ignoreCase = true) || line.startsWith("totp:", ignoreCase = true) -> {
false
}
else -> {
true
}
}
}
.joinToString(separator = "\n")
}
private fun generateExtraContentPairs(): Map<String, String> {
fun MutableMap<String, String>.putOrAppend(key: String, value: String) {
if (value.isEmpty()) return
val existing = this[key]
this[key] =
if (existing == null) {
value
} else {
"$existing\n$value"
}
}
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 digits: String
val USERNAME_FIELDS =
arrayOf(
"login:",
"username:",
"user:",
"account:",
"email:",
"name:",
"handle:",
"id:",
"identity:",
)
@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)
val USERNAME_FIELDS = arrayOf(
"login:",
"username:",
"user:",
"account:",
"email:",
"name:",
"handle:",
"id:",
"identity:",
)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val PASSWORD_FIELDS = arrayOf(
"password:",
"secret:",
"pass:",
)
}
val PASSWORD_FIELDS =
arrayOf(
"password:",
"secret:",
"pass:",
)
}
}

View file

@ -8,79 +8,56 @@ import dev.msfjarvis.aps.ui.crypto.BasePgpActivity
import java.io.File
data class PasswordItem(
val name: String,
val parent: PasswordItem? = null,
val type: Char,
val file: File,
val rootDir: File
val name: String,
val parent: PasswordItem? = null,
val type: Char,
val file: File,
val rootDir: File
) : Comparable<PasswordItem> {
val fullPathToParent = file.absolutePath
.replace(rootDir.absolutePath, "")
.replace(file.name, "")
val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "")
val longName = BasePgpActivity.getLongName(
fullPathToParent,
rootDir.absolutePath,
toString())
val longName = BasePgpActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString())
override fun equals(other: Any?): Boolean {
return (other is PasswordItem) && (other.file == file)
override fun equals(other: Any?): Boolean {
return (other is PasswordItem) && (other.file == file)
}
override fun compareTo(other: PasswordItem): Int {
return (type + name).compareTo(other.type + other.name, ignoreCase = true)
}
override fun 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 {
return (type + name).compareTo(other.type + other.name, ignoreCase = true)
@JvmStatic
fun newCategory(name: String, file: File, rootDir: File): PasswordItem {
return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
}
override fun toString(): String {
return name.replace("\\.gpg$".toRegex(), "")
@JvmStatic
fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
}
override fun hashCode(): Int {
return 0
}
companion object {
const val TYPE_CATEGORY = 'c'
const val TYPE_PASSWORD = 'p'
@JvmStatic
fun newCategory(
name: String,
file: File,
parent: PasswordItem,
rootDir: File
): PasswordItem {
return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
}
@JvmStatic
fun newCategory(
name: String,
file: File,
rootDir: File
): PasswordItem {
return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
}
@JvmStatic
fun newPassword(
name: String,
file: File,
parent: PasswordItem,
rootDir: File
): PasswordItem {
return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
}
@JvmStatic
fun newPassword(
name: String,
file: File,
rootDir: File
): PasswordItem {
return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
}
@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 {
@RequiresApi(Build.VERSION_CODES.O)
private class FS_POSIX_Java6_with_optional_symlinks : FS_POSIX_Java6() {
@RequiresApi(Build.VERSION_CODES.O)
private class FS_POSIX_Java6_with_optional_symlinks : FS_POSIX_Java6() {
override fun supportsSymlinks() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
override fun supportsSymlinks() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
override fun isSymLink(file: File) = Files.isSymbolicLink(file.toPath())
override fun isSymLink(file: File) = Files.isSymbolicLink(file.toPath())
override fun readSymLink(file: File) = Files.readSymbolicLink(file.toPath()).toString()
override fun readSymLink(file: File) = Files.readSymbolicLink(file.toPath()).toString()
override fun createSymLink(source: File, target: String) {
val sourcePath = source.toPath()
if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS))
Files.delete(sourcePath)
Files.createSymbolicLink(sourcePath, File(target).toPath())
}
override fun createSymLink(source: File, target: String) {
val sourcePath = source.toPath()
if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) Files.delete(sourcePath)
Files.createSymbolicLink(sourcePath, File(target).toPath())
}
}
@RequiresApi(Build.VERSION_CODES.O)
private class Java7FSFactory : FS.FSFactory() {
@RequiresApi(Build.VERSION_CODES.O)
private class Java7FSFactory : FS.FSFactory() {
override fun detect(cygwinUsed: Boolean?): FS {
return FS_POSIX_Java6_with_optional_symlinks()
}
override fun detect(cygwinUsed: Boolean?): FS {
return FS_POSIX_Java6_with_optional_symlinks()
}
}
private var repository: Repository? = null
private val settings by lazy(LazyThreadSafetyMode.NONE) { Application.instance.sharedPrefs }
private val filesDir
get() = Application.instance.filesDir
private var repository: Repository? = null
private val settings by lazy(LazyThreadSafetyMode.NONE) { Application.instance.sharedPrefs }
private val filesDir
get() = Application.instance.filesDir
/**
* Returns the git repository
*
* @param localDir needed only on the creation
* @return the git repository
*/
@JvmStatic
fun getRepository(localDir: File?): Repository? {
if (repository == null && localDir != null) {
val builder = FileRepositoryBuilder()
repository = runCatching {
builder.run {
gitDir = localDir
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
fs = Java7FSFactory().detect(null)
}
readEnvironment()
}.build()
}.getOrElse { e ->
e.printStackTrace()
null
/**
* Returns the git repository
*
* @param localDir needed only on the creation
* @return the git repository
*/
@JvmStatic
fun getRepository(localDir: File?): Repository? {
if (repository == null && localDir != null) {
val builder = FileRepositoryBuilder()
repository =
runCatching {
builder
.run {
gitDir = localDir
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
fs = Java7FSFactory().detect(null)
}
readEnvironment()
}
.build()
}
return repository
.getOrElse { e ->
e.printStackTrace()
null
}
}
return repository
}
@JvmStatic
val isInitialized: Boolean
get() = repository != null
@JvmStatic
val isInitialized: Boolean
get() = repository != null
@JvmStatic
fun isGitRepo(): Boolean {
if (repository != null) {
return repository!!.objectDatabase.exists()
@JvmStatic
fun isGitRepo(): Boolean {
if (repository != null) {
return repository!!.objectDatabase.exists()
}
return false
}
@JvmStatic
@Throws(Exception::class)
fun createRepository(localDir: File) {
localDir.delete()
Git.init().setDirectory(localDir).call()
getRepository(localDir)
}
// TODO add multiple remotes support for pull/push
@JvmStatic
fun addRemote(name: String, url: String, replace: Boolean = false) {
val storedConfig = repository!!.config
val remotes = storedConfig.getSubsections("remote")
if (!remotes.contains(name)) {
runCatching {
val uri = URIish(url)
val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*")
val remoteConfig = RemoteConfig(storedConfig, name)
remoteConfig.addFetchRefSpec(refSpec)
remoteConfig.addPushRefSpec(refSpec)
remoteConfig.addURI(uri)
remoteConfig.addPushURI(uri)
remoteConfig.update(storedConfig)
storedConfig.save()
}
.onFailure { e -> e.printStackTrace() }
} else if (replace) {
runCatching {
val uri = URIish(url)
val remoteConfig = RemoteConfig(storedConfig, name)
// remove the first and eventually the only uri
if (remoteConfig.urIs.size > 0) {
remoteConfig.removeURI(remoteConfig.urIs[0])
}
return false
}
@JvmStatic
@Throws(Exception::class)
fun createRepository(localDir: File) {
localDir.delete()
Git.init().setDirectory(localDir).call()
getRepository(localDir)
}
// TODO add multiple remotes support for pull/push
@JvmStatic
fun addRemote(name: String, url: String, replace: Boolean = false) {
val storedConfig = repository!!.config
val remotes = storedConfig.getSubsections("remote")
if (!remotes.contains(name)) {
runCatching {
val uri = URIish(url)
val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*")
val remoteConfig = RemoteConfig(storedConfig, name)
remoteConfig.addFetchRefSpec(refSpec)
remoteConfig.addPushRefSpec(refSpec)
remoteConfig.addURI(uri)
remoteConfig.addPushURI(uri)
remoteConfig.update(storedConfig)
storedConfig.save()
}.onFailure { e ->
e.printStackTrace()
}
} else if (replace) {
runCatching {
val uri = URIish(url)
val remoteConfig = RemoteConfig(storedConfig, name)
// remove the first and eventually the only uri
if (remoteConfig.urIs.size > 0) {
remoteConfig.removeURI(remoteConfig.urIs[0])
}
if (remoteConfig.pushURIs.size > 0) {
remoteConfig.removePushURI(remoteConfig.pushURIs[0])
}
remoteConfig.addURI(uri)
remoteConfig.addPushURI(uri)
remoteConfig.update(storedConfig)
storedConfig.save()
}.onFailure { e ->
e.printStackTrace()
}
if (remoteConfig.pushURIs.size > 0) {
remoteConfig.removePushURI(remoteConfig.pushURIs[0])
}
remoteConfig.addURI(uri)
remoteConfig.addPushURI(uri)
remoteConfig.update(storedConfig)
storedConfig.save()
}
.onFailure { e -> e.printStackTrace() }
}
}
@JvmStatic
fun closeRepository() {
if (repository != null) repository!!.close()
repository = null
}
@JvmStatic
fun getRepositoryDirectory(): File {
return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
if (externalRepo != null) File(externalRepo) else File(filesDir.toString(), "/store")
} else {
File(filesDir.toString(), "/store")
}
}
@JvmStatic
fun initialize(): Repository? {
val dir = getRepositoryDirectory()
// uninitialize the repo if the dir does not exist or is absolutely empty
settings.edit {
if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) {
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
} else {
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true)
}
}
@JvmStatic
fun closeRepository() {
if (repository != null) repository!!.close()
repository = null
}
// create the repository static variable in PasswordRepository
return getRepository(File(dir.absolutePath + "/.git"))
}
@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")
/**
* 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 {
File(filesDir.toString(), "/store")
PasswordItem.newCategory(file.name, file, rootDir)
}
)
}
@JvmStatic
fun initialize(): Repository? {
val dir = getRepositoryDirectory()
// uninitialize the repo if the dir does not exist or is absolutely empty
settings.edit {
if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) {
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
} else {
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true)
}
}
// create the repository static variable in PasswordRepository
return getRepository(File(dir.absolutePath + "/.git"))
}
/**
* Gets the .gpg files in a directory
*
* @param path the directory path
* @return the list of gpg files in that directory
*/
@JvmStatic
fun getFilesList(path: File?): ArrayList<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
}
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
class FieldItemAdapter(
private var fieldItemList: List<FieldItem>,
private val showPassword: Boolean,
private val copyTextToClipBoard: (text: String?) -> Unit,
private var fieldItemList: List<FieldItem>,
private val showPassword: Boolean,
private val copyTextToClipBoard: (text: String?) -> Unit,
) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return FieldItemViewHolder(binding.root, binding)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return FieldItemViewHolder(binding.root, binding)
}
override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
holder.bind(fieldItemList[position], showPassword, copyTextToClipBoard)
}
override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
holder.bind(fieldItemList[position], showPassword, copyTextToClipBoard)
}
override fun getItemCount(): Int {
return fieldItemList.size
}
override fun getItemCount(): Int {
return fieldItemList.size
}
fun updateOTPCode(code: String) {
var otpItemPosition = -1;
fieldItemList = fieldItemList.mapIndexed { position, item ->
if (item.key.equals(FieldItem.ItemType.OTP.type, true)) {
otpItemPosition = position
return@mapIndexed FieldItem.createOtpField(code)
}
return@mapIndexed item
fun updateOTPCode(code: String) {
var otpItemPosition = -1
fieldItemList =
fieldItemList.mapIndexed { position, item ->
if (item.key.equals(FieldItem.ItemType.OTP.type, true)) {
otpItemPosition = position
return@mapIndexed FieldItem.createOtpField(code)
}
notifyItemChanged(otpItemPosition)
}
return@mapIndexed item
}
fun updateItems(itemList: List<FieldItem>) {
fieldItemList = itemList
notifyDataSetChanged()
}
notifyItemChanged(otpItemPosition)
}
class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) :
RecyclerView.ViewHolder(itemView) {
fun updateItems(itemList: List<FieldItem>) {
fieldItemList = itemList
notifyDataSetChanged()
}
fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) {
with(binding) {
itemText.hint = fieldItem.key
itemTextContainer.hint = fieldItem.key
itemText.setText(fieldItem.value)
class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : RecyclerView.ViewHolder(itemView) {
when (fieldItem.action) {
FieldItem.ActionType.COPY -> {
itemTextContainer.apply {
endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
endIconMode = TextInputLayout.END_ICON_CUSTOM
setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
}
}
FieldItem.ActionType.HIDE -> {
itemTextContainer.apply {
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
}
itemText.apply {
if (!showPassword) {
transformationMethod = PasswordTransformationMethod.getInstance()
}
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
}
}
}
fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) {
with(binding) {
itemText.hint = fieldItem.key
itemTextContainer.hint = fieldItem.key
itemText.setText(fieldItem.value)
when (fieldItem.action) {
FieldItem.ActionType.COPY -> {
itemTextContainer.apply {
endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
endIconMode = TextInputLayout.END_ICON_CUSTOM
setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
}
}
FieldItem.ActionType.HIDE -> {
itemTextContainer.apply {
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
}
itemText.apply {
if (!showPassword) {
transformationMethod = PasswordTransformationMethod.getInstance()
}
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
}
}
}
}
}
}
}

View file

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

View file

@ -51,195 +51,184 @@ import org.openintents.openpgp.OpenPgpError
@RequiresApi(Build.VERSION_CODES.O)
class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
companion object {
companion object {
private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH"
private const val EXTRA_SEARCH_ACTION =
"dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION"
private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH"
private const val EXTRA_SEARCH_ACTION = "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION"
private var decryptFileRequestCode = 1
private var decryptFileRequestCode = 1
fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
return Intent(context, AutofillDecryptActivity::class.java).apply {
putExtras(forwardedExtras)
putExtra(EXTRA_SEARCH_ACTION, true)
putExtra(EXTRA_FILE_PATH, file.absolutePath)
}
}
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
val intent = Intent(context, AutofillDecryptActivity::class.java).apply {
putExtra(EXTRA_SEARCH_ACTION, false)
putExtra(EXTRA_FILE_PATH, file.absolutePath)
}
return PendingIntent.getActivity(
context,
decryptFileRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
).intentSender
}
fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
return Intent(context, AutofillDecryptActivity::class.java).apply {
putExtras(forwardedExtras)
putExtra(EXTRA_SEARCH_ACTION, true)
putExtra(EXTRA_FILE_PATH, file.absolutePath)
}
}
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
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
val intent =
Intent(context, AutofillDecryptActivity::class.java).apply {
putExtra(EXTRA_SEARCH_ACTION, false)
putExtra(EXTRA_FILE_PATH, file.absolutePath)
}
return PendingIntent.getActivity(context, decryptFileRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
.intentSender
}
}
private val decryptInteractionRequiredAction =
registerForActivityResult(StartIntentSenderForResult()) { result ->
if (continueAfterUserInteraction != null) {
val data = result.data
if (result.resultCode == RESULT_OK && data != null) {
continueAfterUserInteraction?.resume(data)
} else {
continueAfterUserInteraction?.resumeWithException(
Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")
)
}
continueAfterUserInteraction = null
}
}
private var continueAfterUserInteraction: Continuation<Intent>? = null
private lateinit var directoryStructure: DirectoryStructure
private var continueAfterUserInteraction: Continuation<Intent>? = null
private lateinit var directoryStructure: DirectoryStructure
override val coroutineContext
get() = Dispatchers.IO + SupervisorJob()
override val coroutineContext
get() = Dispatchers.IO + SupervisorJob()
override fun onStart() {
super.onStart()
val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run {
e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
finish()
return
override fun onStart() {
super.onStart()
val filePath =
intent?.getStringExtra(EXTRA_FILE_PATH)
?: run {
e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
finish()
return
}
val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
finish()
return
val clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
?: run {
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
finish()
return
}
val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
directoryStructure = AutofillPreferences.directoryStructure(this)
d { action.toString() }
launch {
val credentials = decryptCredential(File(filePath))
if (credentials == null) {
setResult(RESULT_CANCELED)
} else {
val fillInDataset =
AutofillResponseBuilder.makeFillInDataset(
this@AutofillDecryptActivity,
credentials,
clientState,
action
)
withContext(Dispatchers.Main) {
setResult(RESULT_OK, Intent().apply {
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
})
val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
directoryStructure = AutofillPreferences.directoryStructure(this)
d { action.toString() }
launch {
val credentials = decryptCredential(File(filePath))
if (credentials == null) {
setResult(RESULT_CANCELED)
} else {
val fillInDataset =
AutofillResponseBuilder.makeFillInDataset(this@AutofillDecryptActivity, credentials, clientState, action)
withContext(Dispatchers.Main) {
setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) })
}
}
withContext(Dispatchers.Main) { finish() }
}
}
override fun onDestroy() {
super.onDestroy()
coroutineContext.cancelChildren()
}
private suspend fun executeOpenPgpApi(data: Intent, input: InputStream, output: OutputStream): Intent? {
var openPgpServiceConnection: OpenPgpServiceConnection? = null
val openPgpService =
suspendCoroutine<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() {
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)
}
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
}
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
}
}
}
}
return null
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -42,269 +42,249 @@ import org.openintents.openpgp.OpenPgpError
@Suppress("Registered")
open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
/**
* Full path to the repository
*/
val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! }
/** Full path to the repository */
val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! }
/**
* Full path to the password file being worked on
*/
val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! }
/** Full path to the password file being worked on */
val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! }
/**
* Name of the password file
*
* Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
*/
val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension }
/**
* Name of the password file
*
* Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
*/
val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension }
/**
* Get the timestamp for when this file was last modified.
*/
val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) {
getLastChangedString(
intent.getLongExtra(
"LAST_CHANGED_TIMESTAMP",
-1L
)
)
/** Get the timestamp for when this file was last modified. */
val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) {
getLastChangedString(intent.getLongExtra("LAST_CHANGED_TIMESTAMP", -1L))
}
/** [SharedPreferences] instance used by subclasses to persist settings */
val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs }
/**
* 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()
}
/**
* [SharedPreferences] instance used by subclasses to persist settings
*/
val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs }
return DateUtils.getRelativeTimeSpanString(this, timeStamp, true)
}
/**
* Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain.
*/
private var serviceConnection: OpenPgpServiceConnection? = null
var api: OpenPgpApi? = null
/**
* 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}" }
}
}
}
}
/**
* 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
/**
* 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))
}
}
/**
* [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)
/**
* 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(), "/")
}
/**
* [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
/** /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 {
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)
}
/**
* 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
}
"$relativePath/$basename"
}
} else {
basename
}
}
}
}

View file

@ -37,202 +37,196 @@ import org.openintents.openpgp.IOpenPgpService2
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
private val binding by viewBinding(DecryptLayoutBinding::inflate)
private val binding by viewBinding(DecryptLayoutBinding::inflate)
private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
private var passwordEntry: PasswordEntry? = null
private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
private var passwordEntry: PasswordEntry? = null
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result ->
if (result.data == null) {
setResult(RESULT_CANCELED, null)
finish()
return@registerForActivityResult
}
when (result.resultCode) {
RESULT_OK -> decryptAndVerify(result.data)
RESULT_CANCELED -> {
setResult(RESULT_CANCELED, result.data)
finish()
}
}
}
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)
private val userInteractionRequiredResult =
registerForActivityResult(StartIntentSenderForResult()) { result ->
if (result.data == null) {
setResult(RESULT_CANCELED, null)
finish()
return@registerForActivityResult
}
when (result.resultCode) {
RESULT_OK -> decryptAndVerify(result.data)
RESULT_CANCELED -> {
setResult(RESULT_CANCELED, result.data)
finish()
}
}
}
private fun shareAsPlaintext() {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
type = "text/plain"
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
}
// Always show a picker to give the user a chance to cancel
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)))
}
}
}
@OptIn(ExperimentalTime::class)
private fun decryptAndVerify(receivedIntent: Intent? = null) {
if (api == null) {
bindToOpenKeychain(this)
return
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
}
val data = receivedIntent ?: Intent()
data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY
}
}
return true
}
val inputStream = File(fullPath).inputStream()
val outputStream = ByteArrayOutputStream()
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
}
lifecycleScope.launch(Dispatchers.IO) {
api?.executeApiAsync(data, inputStream, outputStream) { result ->
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
OpenPgpApi.RESULT_CODE_SUCCESS -> {
startAutoDismissTimer()
runCatching {
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
val entry = PasswordEntry(outputStream)
val items = arrayListOf<FieldItem>()
val adapter = FieldItemAdapter(emptyList(), showPassword) { text ->
copyTextToClipboard(text)
}
override fun onBound(service: IOpenPgpService2) {
super.onBound(service)
decryptAndVerify()
}
if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
copyPasswordToClipboard(entry.password)
}
override fun onError(e: Exception) {
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()) {
launch(Dispatchers.IO) {
// Calculate the actual remaining time for the first pass
// then return to the standard 30 second affair.
val remainingTime =
entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod)
withContext(Dispatchers.Main) {
val code = entry.calculateTotpCode() ?: "Error"
items.add(FieldItem.createOtpField(code))
}
delay(remainingTime.seconds)
repeat(Int.MAX_VALUE) {
val code = entry.calculateTotpCode() ?: "Error"
withContext(Dispatchers.Main) {
adapter.updateOTPCode(code)
}
delay(30.seconds)
}
}
}
private fun shareAsPlaintext() {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
type = "text/plain"
}
// Always show a picker to give the user a chance to cancel
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)))
}
if (!entry.username.isNullOrEmpty()) {
items.add(FieldItem.createUsernameField(entry.username))
}
@OptIn(ExperimentalTime::class)
private fun decryptAndVerify(receivedIntent: Intent? = null) {
if (api == null) {
bindToOpenKeychain(this)
return
}
val data = receivedIntent ?: Intent()
data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY
if (entry.hasExtraContentWithoutAuthData()) {
entry.extraContentMap.forEach { (key, value) ->
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
}
}
val inputStream = File(fullPath).inputStream()
val outputStream = ByteArrayOutputStream()
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)
lifecycleScope.launch(Dispatchers.IO) {
api?.executeApiAsync(data, inputStream, outputStream) { result ->
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
OpenPgpApi.RESULT_CODE_SUCCESS -> {
startAutoDismissTimer()
runCatching {
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
val entry = PasswordEntry(outputStream)
val items = arrayListOf<FieldItem>()
val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> copyTextToClipboard(text) }
if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
copyPasswordToClipboard(entry.password)
}
passwordEntry = entry
invalidateOptionsMenu()
if (entry.password.isNotEmpty()) {
items.add(FieldItem.createPasswordField(entry.password))
}
if (entry.hasTotp()) {
launch(Dispatchers.IO) {
// Calculate the actual remaining time for the first pass
// then return to the standard 30 second affair.
val remainingTime = entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod)
withContext(Dispatchers.Main) {
val code = entry.calculateTotpCode() ?: "Error"
items.add(FieldItem.createOtpField(code))
}
delay(remainingTime.seconds)
repeat(Int.MAX_VALUE) {
val code = entry.calculateTotpCode() ?: "Error"
withContext(Dispatchers.Main) { adapter.updateOTPCode(code) }
delay(30.seconds)
}
}
}
if (!entry.username.isNullOrEmpty()) {
items.add(FieldItem.createUsernameField(entry.username))
}
if (entry.hasExtraContentWithoutAuthData()) {
entry.extraContentMap.forEach { (key, value) ->
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
}
}
binding.recyclerView.adapter = adapter
adapter.updateItems(items)
}
.onFailure { e -> e(e) }
}
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
val sender = getUserInteractionRequestIntent(result)
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
}
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
}
}
}
}
}

View file

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

View file

@ -55,454 +55,443 @@ import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
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 suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) }
private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) }
private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
private var oldCategory: String? = null
private var copy: Boolean = false
private var encryptionIntent: Intent = Intent()
private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) {
intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
}
private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) }
private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
private var oldCategory: String? = null
private var copy: Boolean = false
private var encryptionIntent: Intent = Intent()
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result ->
if (result.data == null) {
setResult(RESULT_CANCELED, null)
finish()
return@registerForActivityResult
}
when (result.resultCode) {
RESULT_OK -> encrypt(result.data)
RESULT_CANCELED -> {
setResult(RESULT_CANCELED, result.data)
finish()
}
private val userInteractionRequiredResult =
registerForActivityResult(StartIntentSenderForResult()) { result ->
if (result.data == null) {
setResult(RESULT_CANCELED, null)
finish()
return@registerForActivityResult
}
when (result.resultCode) {
RESULT_OK -> encrypt(result.data)
RESULT_CANCELED -> {
setResult(RESULT_CANCELED, result.data)
finish()
}
}
}
private val otpImportAction = registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
binding.otpImportButton.isVisible = false
val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
val contents = "${intentResult.contents}\n"
private val otpImportAction =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
binding.otpImportButton.isVisible = false
val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
val contents = "${intentResult.contents}\n"
val currentExtras = binding.extraContent.text.toString()
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') binding.extraContent.append("\n$contents")
else binding.extraContent.append(contents)
snackbar(message = getString(R.string.otp_import_success))
} else {
snackbar(message = getString(R.string.otp_import_failure))
}
}
private val gpgKeySelectAction =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
lifecycleScope.launch {
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
withContext(Dispatchers.IO) { gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) }
commitChange(
getString(
R.string.git_commit_gpg_id,
getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name)
)
)
.onSuccess { encrypt(encryptionIntent) }
}
}
}
}
private fun File.findTillRoot(fileName: String, rootPath: File): File? {
val gpgFile = File(this, fileName)
if (gpgFile.exists()) return gpgFile
if (this.absolutePath == rootPath.absolutePath) {
return null
}
val parent = parentFile
return if (parent != null && parent.exists()) {
parent.findTillRoot(fileName, rootPath)
} else {
null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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)
snackbar(message = getString(R.string.otp_import_success))
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 {
snackbar(message = getString(R.string.otp_import_failure))
setBackgroundColor(getColor(android.R.color.transparent))
}
}
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)
}
}
}
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
}
}
private fun File.findTillRoot(fileName: String, rootPath: File): File? {
val gpgFile = File(this, fileName)
if (gpgFile.exists()) return gpgFile
if (this.absolutePath == rootPath.absolutePath) {
return null
}
val parent = parentFile
return if (parent != null && parent.exists()) {
parent.findTillRoot(fileName, rootPath)
} else {
null
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
bindToOpenKeychain(this)
title = if (editing)
getString(R.string.edit_password)
else
getString(R.string.new_password_title)
with(binding) {
setContentView(root)
generatePassword.setOnClickListener { generatePassword() }
otpImportButton.setOnClickListener {
supportFragmentManager.setFragmentResultListener(OTP_RESULT_REQUEST_KEY, this@PasswordCreationActivity) { requestKey, bundle ->
if (requestKey == OTP_RESULT_REQUEST_KEY) {
val contents = bundle.getString(RESULT)
val currentExtras = binding.extraContent.text.toString()
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
binding.extraContent.append("\n$contents")
else
binding.extraContent.append(contents)
}
}
val 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}")
}
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 {
if (visibility != View.VISIBLE)
return@apply
val hasUsernameInFileName = filename.text.toString().isNotBlank()
val hasUsernameInExtras = entry.hasUsername()
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
isChecked = hasUsernameInExtras
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)
}
}
}
}
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
*/
private fun encrypt(receivedIntent: Intent? = null) {
with(binding) {
val editName = filename.text.toString().trim()
val editPass = password.text.toString()
val editExtra = extraContent.text.toString()
/** Encrypts the password and the extra content */
private fun encrypt(receivedIntent: Intent? = null) {
with(binding) {
val editName = filename.text.toString().trim()
val editPass = password.text.toString()
val editExtra = extraContent.text.toString()
if (editName.isEmpty()) {
snackbar(message = resources.getString(R.string.file_toast_text))
return@with
} else if (editName.contains('/')) {
snackbar(message = resources.getString(R.string.invalid_filename_text))
return@with
}
if (editName.isEmpty()) {
snackbar(message = resources.getString(R.string.file_toast_text))
return@with
} else if (editName.contains('/')) {
snackbar(message = resources.getString(R.string.invalid_filename_text))
return@with
}
if (editPass.isEmpty() && editExtra.isEmpty()) {
snackbar(message = resources.getString(R.string.empty_toast_text))
return@with
}
if (editPass.isEmpty() && editExtra.isEmpty()) {
snackbar(message = resources.getString(R.string.empty_toast_text))
return@with
}
if (copy) {
copyPasswordToClipboard(editPass)
}
if (copy) {
copyPasswordToClipboard(editPass)
}
encryptionIntent = receivedIntent ?: Intent()
encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT
encryptionIntent = receivedIntent ?: Intent()
encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT
// pass enters the key ID into `.gpg-id`.
val repoRoot = PasswordRepository.getRepositoryDirectory()
val gpgIdentifierFile = File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot)
?: File(repoRoot, ".gpg-id").apply { createNewFile() }
val gpgIdentifiers = gpgIdentifierFile.readLines()
.filter { it.isNotBlank() }
.map { line ->
GpgIdentifier.fromString(line) ?: run {
// The line being empty means this is most likely an empty `.gpg-id` file
// we created. Skip the validation so we can make the user add a real ID.
if (line.isEmpty()) return@run
if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
snackbar(message = resources.getString(R.string.short_key_ids_unsupported))
} else {
snackbar(message = resources.getString(R.string.invalid_gpg_id))
}
return@with
}
}
if (gpgIdentifiers.isEmpty()) {
gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java))
return@with
}
val keyIds = gpgIdentifiers.filterIsInstance<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)
}
}
// pass enters the key ID into `.gpg-id`.
val repoRoot = PasswordRepository.getRepositoryDirectory()
val gpgIdentifierFile =
File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot)
?: File(repoRoot, ".gpg-id").apply { createNewFile() }
val gpgIdentifiers =
gpgIdentifierFile.readLines().filter { it.isNotBlank() }.map { line ->
GpgIdentifier.fromString(line)
?: run {
// The line being empty means this is most likely an empty `.gpg-id`
// file
// we created. Skip the validation so we can make the user add a real
// ID.
if (line.isEmpty()) return@run
if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
snackbar(message = resources.getString(R.string.short_key_ids_unsupported))
} else {
snackbar(message = resources.getString(R.string.invalid_gpg_id))
}
return@with
}
}
}
if (gpgIdentifiers.isEmpty()) {
gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java))
return@with
}
val keyIds = gpgIdentifiers.filterIsInstance<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"
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"
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)
}
}
}
}
}
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
/**
* [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like
* API through [Builder] to create a similar UI, just at the bottom of the screen.
* [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API
* through [Builder] to create a similar UI, just at the bottom of the screen.
*/
class BasicBottomSheet private constructor(
val title: String?,
val message: String,
val positiveButtonLabel: String?,
val negativeButtonLabel: String?,
val positiveButtonClickListener: View.OnClickListener?,
val negativeButtonClickListener: View.OnClickListener?,
class BasicBottomSheet
private constructor(
val title: String?,
val message: String,
val positiveButtonLabel: String?,
val negativeButtonLabel: String?,
val positiveButtonClickListener: View.OnClickListener?,
val negativeButtonClickListener: View.OnClickListener?,
) : BottomSheetDialogFragment() {
private val binding by viewBinding(BasicBottomSheetBinding::bind)
private val binding by viewBinding(BasicBottomSheetBinding::bind)
private var behavior: BottomSheetBehavior<FrameLayout>? = null
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
private var behavior: BottomSheetBehavior<FrameLayout>? = null
private val bottomSheetCallback =
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
dismiss()
}
}
}
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
dismiss()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
if (savedInstanceState != null) dismiss()
return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.viewTreeObserver.addOnGlobalLayoutListener(
object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
val dialog = dialog as BottomSheetDialog? ?: return
behavior = dialog.behavior
behavior?.apply {
state = BottomSheetBehavior.STATE_EXPANDED
peekHeight = 0
addBottomSheetCallback(bottomSheetCallback)
}
if (!title.isNullOrEmpty()) {
binding.bottomSheetTitle.isVisible = true
binding.bottomSheetTitle.text = title
}
binding.bottomSheetMessage.text = message
if (positiveButtonClickListener != null) {
positiveButtonLabel?.let { buttonLbl -> binding.bottomSheetOkButton.text = buttonLbl }
binding.bottomSheetOkButton.isVisible = true
binding.bottomSheetOkButton.setOnClickListener {
positiveButtonClickListener.onClick(it)
dismiss()
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
if (savedInstanceState != null) dismiss()
return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
val dialog = dialog as BottomSheetDialog? ?: return
behavior = dialog.behavior
behavior?.apply {
state = BottomSheetBehavior.STATE_EXPANDED
peekHeight = 0
addBottomSheetCallback(bottomSheetCallback)
}
if (!title.isNullOrEmpty()) {
binding.bottomSheetTitle.isVisible = true
binding.bottomSheetTitle.text = title
}
binding.bottomSheetMessage.text = message
if (positiveButtonClickListener != null) {
positiveButtonLabel?.let { buttonLbl ->
binding.bottomSheetOkButton.text = buttonLbl
}
binding.bottomSheetOkButton.isVisible = true
binding.bottomSheetOkButton.setOnClickListener {
positiveButtonClickListener.onClick(it)
dismiss()
}
}
if (negativeButtonClickListener != null) {
binding.bottomSheetCancelButton.isVisible = true
negativeButtonLabel?.let { buttonLbl ->
binding.bottomSheetCancelButton.text = buttonLbl
}
binding.bottomSheetCancelButton.setOnClickListener {
negativeButtonClickListener.onClick(it)
dismiss()
}
}
}
if (negativeButtonClickListener != null) {
binding.bottomSheetCancelButton.isVisible = true
negativeButtonLabel?.let { buttonLbl -> binding.bottomSheetCancelButton.text = buttonLbl }
binding.bottomSheetCancelButton.setOnClickListener {
negativeButtonClickListener.onClick(it)
dismiss()
}
})
val gradientDrawable = GradientDrawable().apply {
setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
}
}
view.background = gradientDrawable
}
)
val gradientDrawable =
GradientDrawable().apply { setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) }
view.background = gradientDrawable
}
override fun dismiss() {
super.dismiss()
behavior?.removeBottomSheetCallback(bottomSheetCallback)
}
class Builder(val context: Context) {
private var title: String? = null
private var message: String? = null
private var positiveButtonLabel: String? = null
private var negativeButtonLabel: String? = null
private var positiveButtonClickListener: View.OnClickListener? = null
private var negativeButtonClickListener: View.OnClickListener? = null
fun setTitleRes(@StringRes titleRes: Int): Builder {
this.title = context.resources.getString(titleRes)
return this
}
override fun dismiss() {
super.dismiss()
behavior?.removeBottomSheetCallback(bottomSheetCallback)
fun setTitle(title: String): Builder {
this.title = title
return this
}
class Builder(val context: Context) {
private var title: String? = null
private var message: String? = null
private var positiveButtonLabel: String? = null
private var negativeButtonLabel: String? = null
private var positiveButtonClickListener: View.OnClickListener? = null
private var negativeButtonClickListener: View.OnClickListener? = null
fun setTitleRes(@StringRes titleRes: Int): Builder {
this.title = context.resources.getString(titleRes)
return this
}
fun setTitle(title: String): Builder {
this.title = title
return this
}
fun setMessageRes(@StringRes messageRes: Int): Builder {
this.message = context.resources.getString(messageRes)
return this
}
fun 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 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
)
}
}
}

View file

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

View file

@ -25,53 +25,54 @@ import dev.msfjarvis.aps.util.extensions.resolveAttribute
class ItemCreationBottomSheet : BottomSheetDialogFragment() {
private var behavior: BottomSheetBehavior<FrameLayout>? = null
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {
private var behavior: BottomSheetBehavior<FrameLayout>? = null
private val bottomSheetCallback =
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
dismiss()
}
}
}
override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
dismiss()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
if (savedInstanceState != null) dismiss()
return inflater.inflate(R.layout.item_create_sheet, container, false)
}
override fun 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? {
if (savedInstanceState != null) dismiss()
return inflater.inflate(R.layout.item_create_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
val dialog = dialog as BottomSheetDialog? ?: return
behavior = dialog.behavior
behavior?.apply {
state = BottomSheetBehavior.STATE_EXPANDED
peekHeight = 0
addBottomSheetCallback(bottomSheetCallback)
}
dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener {
setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER))
dismiss()
}
dialog.findViewById<View>(R.id.create_password)?.setOnClickListener {
setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD))
dismiss()
}
}
})
val gradientDrawable = GradientDrawable().apply {
setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
}
view.background = gradientDrawable
}
override fun dismiss() {
super.dismiss()
behavior?.removeBottomSheetCallback(bottomSheetCallback)
}
override fun dismiss() {
super.dismiss()
behavior?.removeBottomSheetCallback(bottomSheetCallback)
}
}

View file

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

View file

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

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.PasswordBuilder
/** A placeholder fragment containing a simple view. */
/** A placeholder fragment containing a simple view. */
class XkPasswordGeneratorDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = MaterialAlertDialogBuilder(requireContext())
val callingActivity = requireActivity()
val inflater = callingActivity.layoutInflater
val binding = FragmentXkpwgenBinding.inflate(inflater)
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
val prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val builder = MaterialAlertDialogBuilder(requireContext())
val callingActivity = requireActivity()
val inflater = callingActivity.layoutInflater
val binding = FragmentXkpwgenBinding.inflate(inflater)
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
val prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
builder.setView(binding.root)
builder.setView(binding.root)
val previousStoredCapStyle: String = runCatching {
prefs.getString(PREF_KEY_CAPITALS_STYLE)!!
}.getOr(DEFAULT_CAPS_STYLE)
val previousStoredCapStyle: String =
runCatching { prefs.getString(PREF_KEY_CAPITALS_STYLE)!! }.getOr(DEFAULT_CAPS_STYLE)
val lastCapitalsStyleIndex: Int = runCatching {
CapsType.valueOf(previousStoredCapStyle).ordinal
}.getOr(DEFAULT_CAPS_INDEX)
binding.xkCapType.setSelection(lastCapitalsStyleIndex)
binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS))
val lastCapitalsStyleIndex: Int =
runCatching { CapsType.valueOf(previousStoredCapStyle).ordinal }.getOr(DEFAULT_CAPS_INDEX)
binding.xkCapType.setSelection(lastCapitalsStyleIndex)
binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS))
binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR))
binding.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK))
binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR))
binding.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK))
binding.xkPasswordText.typeface = monoTypeface
binding.xkPasswordText.typeface = monoTypeface
builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ ->
setPreferences(binding, prefs)
setFragmentResult(
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
bundleOf(PasswordCreationActivity.RESULT to "${binding.xkPasswordText.text}")
)
}
// flip neutral and negative buttons
builder.setNeutralButton(resources.getString(R.string.dialog_cancel)) { _, _ -> }
builder.setNegativeButton(resources.getString(R.string.pwgen_generate), null)
val dialog = builder.setTitle(this.resources.getString(R.string.xkpwgen_title)).create()
dialog.setOnShowListener {
setPreferences(binding, prefs)
makeAndSetPassword(binding)
dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
setPreferences(binding, prefs)
makeAndSetPassword(binding)
}
}
return dialog
builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ ->
setPreferences(binding, prefs)
setFragmentResult(
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
bundleOf(PasswordCreationActivity.RESULT to "${binding.xkPasswordText.text}")
)
}
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
},
)
}
// flip neutral and negative buttons
builder.setNeutralButton(resources.getString(R.string.dialog_cancel)) { _, _ -> }
builder.setNegativeButton(resources.getString(R.string.pwgen_generate), null)
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())
}
}
val dialog = builder.setTitle(this.resources.getString(R.string.xkpwgen_title)).create()
companion object {
dialog.setOnShowListener {
setPreferences(binding, prefs)
makeAndSetPassword(binding)
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'
dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
setPreferences(binding, prefs)
makeAndSetPassword(binding)
}
}
return dialog
}
private fun makeAndSetPassword(binding: FragmentXkpwgenBinding) {
PasswordBuilder(requireContext())
.setNumberOfWords(Integer.valueOf(binding.xkNumWords.text.toString()))
.setMinimumWordLength(DEFAULT_MIN_WORD_LENGTH)
.setMaximumWordLength(DEFAULT_MAX_WORD_LENGTH)
.setSeparator(binding.xkSeparator.text.toString())
.appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT })
.appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL })
.setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString()))
.create()
.fold(
success = { binding.xkPasswordText.text = it },
failure = { e ->
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
tag("xkpw").e(e, "failure generating xkpasswd")
binding.xkPasswordText.text = FALLBACK_ERROR_PASS
},
)
}
private fun setPreferences(binding: FragmentXkpwgenBinding, prefs: SharedPreferences) {
prefs.edit {
putString(PREF_KEY_CAPITALS_STYLE, binding.xkCapType.selectedItem.toString())
putString(PREF_KEY_NUM_WORDS, binding.xkNumWords.text.toString())
putString(PREF_KEY_SEPARATOR, binding.xkSeparator.text.toString())
putString(PREF_KEY_EXTRA_SYMBOLS_MASK, binding.xkNumberSymbolMask.text.toString())
}
}
companion object {
const val PREF_KEY_CAPITALS_STYLE = "pref_key_capitals_style"
const val PREF_KEY_NUM_WORDS = "pref_key_num_words"
const val PREF_KEY_SEPARATOR = "pref_key_separator"
const val PREF_KEY_EXTRA_SYMBOLS_MASK = "pref_key_xkpwgen_extra_symbols_mask"
val DEFAULT_CAPS_STYLE = CapsType.Sentence.name
val DEFAULT_CAPS_INDEX = CapsType.Sentence.ordinal
const val DEFAULT_NUMBER_OF_WORDS = "3"
const val DEFAULT_WORD_SEPARATOR = "."
const val DEFAULT_EXTRA_SYMBOLS_MASK = "ds"
const val DEFAULT_MIN_WORD_LENGTH = 3
const val DEFAULT_MAX_WORD_LENGTH = 9
const val FALLBACK_ERROR_PASS = "42"
const val EXTRA_CHAR_PLACEHOLDER_DIGIT = 'd'
const val EXTRA_CHAR_PLACEHOLDER_SYMBOL = 's'
}
}

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.PasswordStore
class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
private lateinit var passwordList: SelectFolderFragment
private lateinit var passwordList: SelectFolderFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
passwordList = SelectFolderFragment()
val args = Bundle()
args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
passwordList = SelectFolderFragment()
val args = Bundle()
args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
passwordList.arguments = args
passwordList.arguments = args
supportActionBar?.show()
supportActionBar?.show()
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
supportFragmentManager.commit {
replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG)
}
supportFragmentManager.commit { replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) }
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.pgp_handler_select_folder, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
android.R.id.home -> {
setResult(RESULT_CANCELED)
onBackPressed()
}
R.id.crypto_select -> selectFolder()
else -> return super.onOptionsItemSelected(item)
}
return true
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.pgp_handler_select_folder, menu)
return true
}
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()
}
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) {
private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
private lateinit var listener: OnFragmentInteractionListener
private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
private lateinit var listener: OnFragmentInteractionListener
private val model: SearchableRepositoryViewModel by activityViewModels()
private val model: SearchableRepositoryViewModel by activityViewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.fab.hide()
recyclerAdapter = PasswordItemRecyclerAdapter()
.onItemClicked { _, item ->
listener.onFragmentInteraction(item)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.fab.hide()
recyclerAdapter = PasswordItemRecyclerAdapter().onItemClicked { _, item -> listener.onFragmentInteraction(item) }
binding.passRecycler.apply {
layoutManager = LinearLayoutManager(requireContext())
itemAnimator = null
adapter = recyclerAdapter
}
FastScrollerBuilder(binding.passRecycler).build()
registerForContextMenu(binding.passRecycler)
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
model.searchResult.observe(viewLifecycleOwner) { result -> recyclerAdapter.submitList(result.passwordItems) }
}
override fun onAttach(context: Context) {
super.onAttach(context)
runCatching {
listener =
object : OnFragmentInteractionListener {
override fun onFragmentInteraction(item: PasswordItem) {
if (item.type == PasswordItem.TYPE_CATEGORY) {
model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
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) {
super.onAttach(context)
runCatching {
listener = object : OnFragmentInteractionListener {
override fun onFragmentInteraction(item: PasswordItem) {
if (item.type == PasswordItem.TYPE_CATEGORY) {
model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
}
}
}.onFailure {
throw ClassCastException("$context must implement OnFragmentInteractionListener")
}
}
val currentDir: File
get() = model.currentDir.value!!
val currentDir: File
get() = model.currentDir.value!!
interface OnFragmentInteractionListener {
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
/**
* Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related
* tasks and makes sense to be held here.
* Abstract [AppCompatActivity] that holds some information that is commonly shared across
* git-related tasks and makes sense to be held here.
*/
abstract class BaseGitActivity : ContinuationContainerActivity() {
/**
* Enum of possible Git operations than can be run through [launchGitOperation].
*/
enum class GitOp {
/** Enum of possible Git operations than can be run through [launchGitOperation]. */
enum class GitOp {
BREAK_OUT_OF_DETACHED,
CLONE,
PULL,
PUSH,
RESET,
SYNC,
}
BREAK_OUT_OF_DETACHED,
CLONE,
PULL,
PUSH,
RESET,
SYNC,
/**
* Attempt to launch the requested Git operation.
* @param operation The type of git operation to launch
*/
suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> {
if (GitSettings.url == null) {
return Err(IllegalStateException("Git url is not set!"))
}
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)
}
/**
* Attempt to launch the requested Git operation.
* @param operation The type of git operation to launch
*/
suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> {
if (GitSettings.url == null) {
return Err(IllegalStateException("Git url is not set!"))
}
if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) {
// If the server does not support multiple SSH channels per connection, we cannot run
// a sync operation without reconnecting and thus break sync into its two parts.
return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) }
}
val op = when (operation) {
GitOp.CLONE -> CloneOperation(this, GitSettings.url!!)
GitOp.PULL -> PullOperation(this, GitSettings.rebaseOnPull)
GitOp.PUSH -> PushOperation(this)
GitOp.SYNC -> SyncOperation(this, GitSettings.rebaseOnPull)
GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this)
GitOp.RESET -> ResetToRemoteOperation(this)
}
return op.executeAfterAuthentication(GitSettings.authMode).mapError(::transformGitError)
}
fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) {
finish()
}
fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) {
finish()
}
suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
val error = rootCauseException(err)
if (!isExplicitlyUserInitiatedError(error)) {
getEncryptedGitPrefs().edit {
remove(PreferenceKeys.HTTPS_PASSWORD)
}
sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
d(error)
withContext(Dispatchers.Main) {
MaterialAlertDialogBuilder(this@BaseGitActivity).run {
setTitle(resources.getString(R.string.jgit_error_dialog_title))
setMessage(ErrorMessages[error])
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
setOnDismissListener {
onPromptDone()
}
show()
}
}
} else {
onPromptDone()
suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
val error = rootCauseException(err)
if (!isExplicitlyUserInitiatedError(error)) {
getEncryptedGitPrefs().edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
d(error)
withContext(Dispatchers.Main) {
MaterialAlertDialogBuilder(this@BaseGitActivity).run {
setTitle(resources.getString(R.string.jgit_error_dialog_title))
setMessage(ErrorMessages[error])
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
setOnDismissListener { onPromptDone() }
show()
}
}
} else {
onPromptDone()
}
}
/**
* Takes the result of [launchGitOperation] and applies any necessary transformations
* on the [throwable] returned from it
*/
private fun transformGitError(throwable: Throwable): Throwable {
val err = rootCauseException(throwable)
return when {
err.message?.contains("cannot open additional channels") == true -> {
GitSettings.useMultiplexing = false
SSHException(DisconnectReason.TOO_MANY_CONNECTIONS, "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used.")
}
err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> {
IllegalStateException("Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings")
}
err is TransportException && err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> {
SSHException(DisconnectReason.HOST_KEY_NOT_VERIFIABLE,
"WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."
)
}
else -> {
err
}
}
/**
* Takes the result of [launchGitOperation] and applies any necessary transformations on the
* [throwable] returned from it
*/
private fun transformGitError(throwable: Throwable): Throwable {
val err = rootCauseException(throwable)
return when {
err.message?.contains("cannot open additional channels") == true -> {
GitSettings.useMultiplexing = false
SSHException(
DisconnectReason.TOO_MANY_CONNECTIONS,
"The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used."
)
}
err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> {
IllegalStateException(
"Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings"
)
}
err is TransportException && err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> {
SSHException(
DisconnectReason.HOST_KEY_NOT_VERIFIABLE,
"WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."
)
}
else -> {
err
}
}
}
/**
* Check if a given [Throwable] is the result of an error caused by the user cancelling the
* operation.
*/
private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean {
var cause: Throwable? = throwable
while (cause != null) {
if (cause is SSHException &&
cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER)
return true
cause = cause.cause
}
return false
/**
* Check if a given [Throwable] is the result of an error caused by the user cancelling the
* operation.
*/
private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean {
var cause: Throwable? = throwable
while (cause != null) {
if (cause is SSHException && cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) return true
cause = cause.cause
}
return false
}
/**
* Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no
* longer found.
*/
private fun rootCauseException(throwable: Throwable): Throwable {
var rootCause = throwable
// JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ exceptions.
// Also, SSHJ's UserAuthException about exhausting available authentication methods hides
// more useful exceptions.
while ((rootCause is org.eclipse.jgit.errors.TransportException ||
rootCause is org.eclipse.jgit.api.errors.TransportException ||
rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException ||
(rootCause is UserAuthException &&
rootCause.message == "Exhausted available authentication methods"))) {
rootCause = rootCause.cause ?: break
}
return rootCause
/**
* Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no
* longer found.
*/
private fun rootCauseException(throwable: Throwable): Throwable {
var rootCause = throwable
// JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ
// exceptions.
// Also, SSHJ's UserAuthException about exhausting available authentication methods hides
// more useful exceptions.
while ((rootCause is org.eclipse.jgit.errors.TransportException ||
rootCause is org.eclipse.jgit.api.errors.TransportException ||
rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException ||
(rootCause is UserAuthException && rootCause.message == "Exhausted available authentication methods"))) {
rootCause = rootCause.cause ?: break
}
return rootCause
}
}

View file

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

View file

@ -41,233 +41,226 @@ import kotlinx.coroutines.withContext
*/
class GitServerConfigActivity : BaseGitActivity() {
private val binding by viewBinding(ActivityGitCloneBinding::inflate)
private val binding by viewBinding(ActivityGitCloneBinding::inflate)
private lateinit var newAuthMode: AuthMode
private lateinit var newAuthMode: AuthMode
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val isClone = intent?.extras?.getBoolean("cloning") ?: false
if (isClone) {
binding.saveButton.text = getString(R.string.clone_button)
}
setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
newAuthMode = GitSettings.authMode
binding.authModeGroup.apply {
when (newAuthMode) {
AuthMode.SshKey -> check(binding.authModeSshKey.id)
AuthMode.Password -> check(binding.authModePassword.id)
AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id)
AuthMode.None -> check(View.NO_ID)
}
setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
binding.authModePassword.id -> newAuthMode = AuthMode.Password
View.NO_ID -> newAuthMode = AuthMode.None
}
}
}
binding.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 onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val isClone = intent?.extras?.getBoolean("cloning") ?: false
if (isClone) {
binding.saveButton.text = getString(R.string.clone_button)
}
setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
newAuthMode = GitSettings.authMode
binding.authModeGroup.apply {
when (newAuthMode) {
AuthMode.SshKey -> check(binding.authModeSshKey.id)
AuthMode.Password -> check(binding.authModePassword.id)
AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id)
AuthMode.None -> check(View.NO_ID)
}
setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
binding.authModePassword.id -> newAuthMode = AuthMode.Password
View.NO_ID -> newAuthMode = AuthMode.None
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
onBackPressed()
true
}
else -> super.onOptionsItemSelected(item)
}
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://"))
}
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)
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 {
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
*/
private fun cloneRepository() {
val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory())
val localDirFiles = localDir.listFiles() ?: emptyArray()
// Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
if (localDir.exists() && localDirFiles.isNotEmpty() &&
!(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_delete_title)
.setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
.setCancelable(false)
.setPositiveButton(R.string.dialog_delete) { dialog, _ ->
runCatching {
lifecycleScope.launch {
val snackbar = snackbar(message = getString(R.string.delete_directory_progress_text), length = Snackbar.LENGTH_INDEFINITE)
withContext(Dispatchers.IO) {
localDir.deleteRecursively()
}
snackbar.dismiss()
launchGitOperation(GitOp.CLONE).fold(
success = {
setResult(RESULT_OK)
finish()
},
failure = { err ->
promptOnErrorHandler(err) {
finish()
}
}
)
}
}.onFailure { e ->
e.printStackTrace()
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
}
dialog.cancel()
}
.setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ ->
dialog.cancel()
}
.show()
} else {
runCatching {
// Silently delete & replace the lone .git folder if it exists
if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
localDir.deleteRecursively()
}
}.onFailure { e ->
e(e)
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
}
/** Clones the repository, the directory exists, deletes it */
private fun cloneRepository() {
val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory())
val localDirFiles = localDir.listFiles() ?: emptyArray()
// Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
if (localDir.exists() && localDirFiles.isNotEmpty() && !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")
) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_delete_title)
.setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
.setCancelable(false)
.setPositiveButton(R.string.dialog_delete) { dialog, _ ->
runCatching {
lifecycleScope.launch {
launchGitOperation(GitOp.CLONE).fold(
success = {
setResult(RESULT_OK)
finish()
},
failure = { promptOnErrorHandler(it) },
val snackbar =
snackbar(
message = getString(R.string.delete_directory_progress_text),
length = Snackbar.LENGTH_INDEFINITE
)
withContext(Dispatchers.IO) { localDir.deleteRecursively() }
snackbar.dismiss()
launchGitOperation(GitOp.CLONE)
.fold(
success = {
setResult(RESULT_OK)
finish()
},
failure = { err -> promptOnErrorHandler(err) { finish() } }
)
}
}
}
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)
}
.onFailure { e ->
e.printStackTrace()
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
}
dialog.cancel()
}
.setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() }
.show()
} else {
runCatching {
// Silently delete & replace the lone .git folder if it exists
if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
localDir.deleteRecursively()
}
}
.onFailure { e ->
e(e)
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
}
lifecycleScope.launch {
launchGitOperation(GitOp.CLONE)
.fold(
success = {
setResult(RESULT_OK)
finish()
},
failure = { promptOnErrorHandler(it) },
)
}
}
}
companion object {
private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
fun createCloneIntent(context: Context): Intent {
return Intent(context, GitServerConfigActivity::class.java).apply { putExtra("cloning", true) }
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -33,94 +33,94 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider {
private val isAutofillServiceEnabled: Boolean
get() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return activity.autofillManager?.hasEnabledAutofillServices() == true
}
@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()
}
private val isAutofillServiceEnabled: Boolean
get() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return activity.autofillManager?.hasEnabledAutofillServices() == true
}
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
}
@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) {
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() {
@Suppress("DEPRECATION")
private val directorySelectAction = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
if (uri == null) return@registerForActivityResult
@Suppress("DEPRECATION")
private val directorySelectAction =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
if (uri == null) return@registerForActivityResult
d { "Selected repository URI is $uri" }
// TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile
val docId = DocumentsContract.getTreeDocumentId(uri)
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
val path = if (split.size > 1) split[1] else split[0]
val repoPath = "${Environment.getExternalStorageDirectory()}/$path"
val prefs = sharedPrefs
d { "Selected repository URI is $uri" }
// TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile
val docId = DocumentsContract.getTreeDocumentId(uri)
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
val path = if (split.size > 1) split[1] else split[0]
val repoPath = "${Environment.getExternalStorageDirectory()}/$path"
val prefs = sharedPrefs
d { "Selected repository path is $repoPath" }
d { "Selected repository path is $repoPath" }
if (Environment.getExternalStorageDirectory().path == repoPath) {
MaterialAlertDialogBuilder(this)
.setTitle(resources.getString(R.string.sdcard_root_warning_title))
.setMessage(resources.getString(R.string.sdcard_root_warning_message))
.setPositiveButton(resources.getString(R.string.sdcard_root_warning_remove_everything)) { _, _ ->
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) }
}
.setNegativeButton(R.string.dialog_cancel, null)
.show()
}
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) }
setResult(RESULT_OK)
finish()
if (Environment.getExternalStorageDirectory().path == repoPath) {
MaterialAlertDialogBuilder(this)
.setTitle(resources.getString(R.string.sdcard_root_warning_title))
.setMessage(resources.getString(R.string.sdcard_root_warning_message))
.setPositiveButton(resources.getString(R.string.sdcard_root_warning_remove_everything)) { _, _ ->
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) }
}
.setNegativeButton(R.string.dialog_cancel, null)
.show()
}
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) }
setResult(RESULT_OK)
finish()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
directorySelectAction.launch(null)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
directorySelectAction.launch(null)
}
}

View file

@ -22,83 +22,85 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider {
override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply {
val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
val themeItems = themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
singleChoice(PreferenceKeys.APP_THEME, themeItems) {
initialSelection = activity.resources.getString(R.string.app_theme_def)
titleRes = R.string.pref_app_theme_title
}
override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply {
val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
val themeItems = themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
singleChoice(PreferenceKeys.APP_THEME, themeItems) {
initialSelection = activity.resources.getString(R.string.app_theme_def)
titleRes = R.string.pref_app_theme_title
}
val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
initialSelection = sortValues[0]
titleRes = R.string.pref_sort_order_title
}
val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
initialSelection = sortValues[0]
titleRes = R.string.pref_sort_order_title
}
checkBox(PreferenceKeys.FILTER_RECURSIVELY) {
titleRes = R.string.pref_recursive_filter_title
summaryRes = R.string.pref_recursive_filter_summary
defaultValue = true
}
checkBox(PreferenceKeys.FILTER_RECURSIVELY) {
titleRes = R.string.pref_recursive_filter_title
summaryRes = R.string.pref_recursive_filter_summary
defaultValue = true
}
checkBox(PreferenceKeys.SEARCH_ON_START) {
titleRes = R.string.pref_search_on_start_title
summaryRes = R.string.pref_search_on_start_summary
defaultValue = false
}
checkBox(PreferenceKeys.SEARCH_ON_START) {
titleRes = R.string.pref_search_on_start_title
summaryRes = R.string.pref_search_on_start_summary
defaultValue = false
}
checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
titleRes = R.string.pref_show_hidden_title
summaryRes = R.string.pref_show_hidden_summary
defaultValue = false
}
checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
titleRes = R.string.pref_show_hidden_title
summaryRes = R.string.pref_show_hidden_summary
defaultValue = false
}
checkBox(PreferenceKeys.BIOMETRIC_AUTH) {
titleRes = R.string.pref_biometric_auth_title
defaultValue = false
}.apply {
val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
if (!canAuthenticate) {
enabled = false
checked = false
summaryRes = R.string.pref_biometric_auth_summary_error
} else {
summaryRes = R.string.pref_biometric_auth_summary
onClick {
enabled = false
val isChecked = checked
activity.sharedPrefs.edit {
BiometricAuthenticator.authenticate(activity) { result ->
when (result) {
is BiometricAuthenticator.Result.Success -> {
// Apply the changes
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked)
enabled = true
}
else -> {
// If any error occurs, revert back to the previous state. This
// catch-all clause includes the cancellation case.
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked)
checked = !isChecked
enabled = true
}
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
activity.getSystemService<ShortcutManager>()?.apply {
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
}
}
false
checkBox(PreferenceKeys.BIOMETRIC_AUTH) {
titleRes = R.string.pref_biometric_auth_title
defaultValue = false
}
.apply {
val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
if (!canAuthenticate) {
enabled = false
checked = false
summaryRes = R.string.pref_biometric_auth_summary_error
} else {
summaryRes = R.string.pref_biometric_auth_summary
onClick {
enabled = false
val isChecked = checked
activity.sharedPrefs.edit {
BiometricAuthenticator.authenticate(activity) { result ->
when (result) {
is BiometricAuthenticator.Result.Success -> {
// Apply the changes
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked)
enabled = true
}
else -> {
// If any error occurs, revert back to the previous
// state. This
// catch-all clause includes the cancellation case.
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked)
checked = !isChecked
enabled = true
}
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
activity.getSystemService<ShortcutManager>()?.apply {
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
}
}
false
}
}
}
}
}
}

View file

@ -23,54 +23,59 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class MiscSettings(activity: FragmentActivity) : SettingsProvider {
private val storeExportAction = activity.registerForActivityResult(object : ActivityResultContracts.OpenDocumentTree() {
private val storeExportAction =
activity.registerForActivityResult(
object : ActivityResultContracts.OpenDocumentTree() {
override fun createIntent(context: Context, input: Uri?): Intent {
return super.createIntent(context, input).apply {
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
}
return super.createIntent(context, input).apply {
flags =
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
}
}
}) { uri: Uri? ->
if (uri == null) return@registerForActivityResult
val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
}
) { uri: Uri? ->
if (uri == null) return@registerForActivityResult
val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
if (targetDirectory != null) {
val service = Intent(activity.applicationContext, PasswordExportService::class.java).apply {
action = PasswordExportService.ACTION_EXPORT_PASSWORD
putExtra("uri", uri)
}
if (targetDirectory != null) {
val service =
Intent(activity.applicationContext, PasswordExportService::class.java).apply {
action = PasswordExportService.ACTION_EXPORT_PASSWORD
putExtra("uri", uri)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(service)
} else {
activity.startService(service)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
activity.startForegroundService(service)
} else {
activity.startService(service)
}
}
}
override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply {
pref(PreferenceKeys.EXPORT_PASSWORDS) {
titleRes = R.string.prefs_export_passwords_title
summaryRes = R.string.prefs_export_passwords_summary
onClick {
storeExportAction.launch(null)
true
}
}
checkBox(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) {
defaultValue = false
titleRes = R.string.pref_clear_clipboard_title
summaryRes = R.string.pref_clear_clipboard_summary
}
checkBox(PreferenceKeys.ENABLE_DEBUG_LOGGING) {
defaultValue = false
titleRes = R.string.pref_debug_logging_title
summaryRes = R.string.pref_debug_logging_summary
visible = !BuildConfig.DEBUG
}
override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply {
pref(PreferenceKeys.EXPORT_PASSWORDS) {
titleRes = R.string.prefs_export_passwords_title
summaryRes = R.string.prefs_export_passwords_summary
onClick {
storeExportAction.launch(null)
true
}
}
checkBox(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) {
defaultValue = false
titleRes = R.string.pref_clear_clipboard_title
summaryRes = R.string.pref_clear_clipboard_summary
}
checkBox(PreferenceKeys.ENABLE_DEBUG_LOGGING) {
defaultValue = false
titleRes = R.string.pref_debug_logging_title
summaryRes = R.string.pref_debug_logging_summary
visible = !BuildConfig.DEBUG
}
}
}
}

View file

@ -29,85 +29,90 @@ import java.io.File
class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider {
private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs }
private val storeCustomXkpwdDictionaryAction = activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@registerForActivityResult
private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs }
private val storeCustomXkpwdDictionaryAction =
activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@registerForActivityResult
Toast.makeText(
activity,
activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path),
Toast.LENGTH_SHORT
).show()
Toast.makeText(
activity,
activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path),
Toast.LENGTH_SHORT
)
.show()
sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) }
sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) }
val inputStream = activity.contentResolver.openInputStream(uri)
val customDictFile = File(activity.filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream()
inputStream?.copyTo(customDictFile, 1024)
inputStream?.close()
customDictFile.close()
val inputStream = activity.contentResolver.openInputStream(uri)
val customDictFile = File(activity.filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream()
inputStream?.copyTo(customDictFile, 1024)
inputStream?.close()
customDictFile.close()
}
override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply {
val customDictPref = CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply {
titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title
summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off
summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
onCheckedChange {
requestRebind()
true
}
}
val customDictPathPref = Preference(PreferenceKeys.PREF_KEY_CUSTOM_DICT).apply {
dependency = PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT
titleRes = R.string.pref_xkpwgen_custom_dict_picker_title
summary = sharedPrefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
?: activity.resources.getString(R.string.pref_xkpwgen_custom_dict_picker_summary)
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
onClick {
storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*"))
true
}
}
val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels)
val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) }
singleChoice(
PreferenceKeys.PREF_KEY_PWGEN_TYPE,
items,
) {
initialSelection = "classic"
titleRes = R.string.pref_password_generator_type_title
onSelectionChange { selection ->
val xkpasswdEnabled = selection == "xkpasswd"
customDictPathPref.visible = xkpasswdEnabled
customDictPref.visible = xkpasswdEnabled
customDictPref.requestRebind()
customDictPathPref.requestRebind()
true
}
}
// We initialize them early and add them manually to be able to manually force a rebind
// when the password generator type is changed.
addPreferenceItem(customDictPref)
addPreferenceItem(customDictPathPref)
editText(PreferenceKeys.GENERAL_SHOW_TIME) {
titleRes = R.string.pref_clipboard_timeout_title
summaryProvider = { activity.getString(R.string.pref_clipboard_timeout_summary) }
textInputType = InputType.TYPE_CLASS_NUMBER
}
checkBox(PreferenceKeys.SHOW_PASSWORD) {
titleRes = R.string.show_password_pref_title
summaryRes = R.string.show_password_pref_summary
defaultValue = true
}
checkBox(PreferenceKeys.COPY_ON_DECRYPT) {
titleRes = R.string.pref_copy_title
summaryRes = R.string.pref_copy_summary
defaultValue = false
}
override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply {
val customDictPref =
CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply {
titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title
summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off
summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
onCheckedChange {
requestRebind()
true
}
}
val customDictPathPref =
Preference(PreferenceKeys.PREF_KEY_CUSTOM_DICT).apply {
dependency = PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT
titleRes = R.string.pref_xkpwgen_custom_dict_picker_title
summary =
sharedPrefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
?: activity.resources.getString(R.string.pref_xkpwgen_custom_dict_picker_summary)
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
onClick {
storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*"))
true
}
}
val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels)
val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) }
singleChoice(
PreferenceKeys.PREF_KEY_PWGEN_TYPE,
items,
) {
initialSelection = "classic"
titleRes = R.string.pref_password_generator_type_title
onSelectionChange { selection ->
val xkpasswdEnabled = selection == "xkpasswd"
customDictPathPref.visible = xkpasswdEnabled
customDictPref.visible = xkpasswdEnabled
customDictPref.requestRebind()
customDictPathPref.requestRebind()
true
}
}
// We initialize them early and add them manually to be able to manually force a rebind
// when the password generator type is changed.
addPreferenceItem(customDictPref)
addPreferenceItem(customDictPathPref)
editText(PreferenceKeys.GENERAL_SHOW_TIME) {
titleRes = R.string.pref_clipboard_timeout_title
summaryProvider = { activity.getString(R.string.pref_clipboard_timeout_summary) }
textInputType = InputType.TYPE_CLASS_NUMBER
}
checkBox(PreferenceKeys.SHOW_PASSWORD) {
titleRes = R.string.show_password_pref_title
summaryRes = R.string.show_password_pref_summary
defaultValue = true
}
checkBox(PreferenceKeys.COPY_ON_DECRYPT) {
titleRes = R.string.pref_copy_title
summaryRes = R.string.pref_copy_summary
defaultValue = false
}
}
}
}

View file

@ -37,168 +37,165 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider {
private val encryptedPreferences by lazy(LazyThreadSafetyMode.NONE) { activity.getEncryptedGitPrefs() }
private val encryptedPreferences by lazy(LazyThreadSafetyMode.NONE) { activity.getEncryptedGitPrefs() }
private fun <T : FragmentActivity> launchActivity(clazz: Class<T>) {
activity.startActivity(Intent(activity, clazz))
}
private fun <T : FragmentActivity> launchActivity(clazz: Class<T>) {
activity.startActivity(Intent(activity, clazz))
}
private fun selectExternalGitRepository() {
MaterialAlertDialogBuilder(activity)
.setTitle(activity.resources.getString(R.string.external_repository_dialog_title))
.setMessage(activity.resources.getString(R.string.external_repository_dialog_text))
.setPositiveButton(R.string.dialog_ok) { _, _ ->
launchActivity(DirectorySelectionActivity::class.java)
}
.setNegativeButton(R.string.dialog_cancel, null)
.show()
}
private fun selectExternalGitRepository() {
MaterialAlertDialogBuilder(activity)
.setTitle(activity.resources.getString(R.string.external_repository_dialog_title))
.setMessage(activity.resources.getString(R.string.external_repository_dialog_text))
.setPositiveButton(R.string.dialog_ok) { _, _ -> launchActivity(DirectorySelectionActivity::class.java) }
.setNegativeButton(R.string.dialog_cancel, null)
.show()
}
override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply {
checkBox(PreferenceKeys.REBASE_ON_PULL) {
titleRes = R.string.pref_rebase_on_pull_title
summaryRes = R.string.pref_rebase_on_pull_summary
summaryOnRes = R.string.pref_rebase_on_pull_summary_on
defaultValue = true
}
pref(PreferenceKeys.GIT_SERVER_INFO) {
titleRes = R.string.pref_edit_git_server_settings
visible = PasswordRepository.isGitRepo()
onClick {
launchActivity(GitServerConfigActivity::class.java)
true
}
}
pref(PreferenceKeys.PROXY_SETTINGS) {
titleRes = R.string.pref_edit_proxy_settings
visible = GitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo()
onClick {
launchActivity(ProxySelectorActivity::class.java)
true
}
}
pref(PreferenceKeys.GIT_CONFIG) {
titleRes = R.string.pref_edit_git_config
visible = PasswordRepository.isGitRepo()
onClick {
launchActivity(GitConfigActivity::class.java)
true
}
}
pref(PreferenceKeys.SSH_KEY) {
titleRes = R.string.pref_import_ssh_key_title
visible = PasswordRepository.isGitRepo()
onClick {
launchActivity(SshKeyImportActivity::class.java)
true
}
}
pref(PreferenceKeys.SSH_KEYGEN) {
titleRes = R.string.pref_ssh_keygen_title
onClick {
launchActivity(SshKeyGenActivity::class.java)
true
}
}
pref(PreferenceKeys.SSH_SEE_KEY) {
titleRes = R.string.pref_ssh_see_key_title
visible = PasswordRepository.isGitRepo()
onClick {
ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
true
}
}
pref(PreferenceKeys.CLEAR_SAVED_PASS) {
fun Preference.updatePref() {
val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
if (sshPass == null && httpsPass == null) {
visible = false
return
}
when {
httpsPass != null -> titleRes = R.string.clear_saved_passphrase_https
sshPass != null -> titleRes = R.string.clear_saved_passphrase_ssh
}
visible = true
requestRebind()
}
onClick {
updatePref()
true
}
updatePref()
}
pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
titleRes = R.string.pref_title_openkeystore_clear_keyid
visible = activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty()
?: false
onClick {
activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
visible = false
true
}
}
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
}
}
override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply {
checkBox(PreferenceKeys.REBASE_ON_PULL) {
titleRes = R.string.pref_rebase_on_pull_title
summaryRes = R.string.pref_rebase_on_pull_summary
summaryOnRes = R.string.pref_rebase_on_pull_summary_on
defaultValue = true
}
pref(PreferenceKeys.GIT_SERVER_INFO) {
titleRes = R.string.pref_edit_git_server_settings
visible = PasswordRepository.isGitRepo()
onClick {
launchActivity(GitServerConfigActivity::class.java)
true
}
}
pref(PreferenceKeys.PROXY_SETTINGS) {
titleRes = R.string.pref_edit_proxy_settings
visible = GitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo()
onClick {
launchActivity(ProxySelectorActivity::class.java)
true
}
}
pref(PreferenceKeys.GIT_CONFIG) {
titleRes = R.string.pref_edit_git_config
visible = PasswordRepository.isGitRepo()
onClick {
launchActivity(GitConfigActivity::class.java)
true
}
}
pref(PreferenceKeys.SSH_KEY) {
titleRes = R.string.pref_import_ssh_key_title
visible = PasswordRepository.isGitRepo()
onClick {
launchActivity(SshKeyImportActivity::class.java)
true
}
}
pref(PreferenceKeys.SSH_KEYGEN) {
titleRes = R.string.pref_ssh_keygen_title
onClick {
launchActivity(SshKeyGenActivity::class.java)
true
}
}
pref(PreferenceKeys.SSH_SEE_KEY) {
titleRes = R.string.pref_ssh_see_key_title
visible = PasswordRepository.isGitRepo()
onClick {
ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
true
}
}
pref(PreferenceKeys.CLEAR_SAVED_PASS) {
fun Preference.updatePref() {
val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
if (sshPass == null && httpsPass == null) {
visible = false
return
}
when {
httpsPass != null -> titleRes = R.string.clear_saved_passphrase_https
sshPass != null -> titleRes = R.string.clear_saved_passphrase_ssh
}
visible = true
requestRebind()
}
onClick {
updatePref()
true
}
updatePref()
}
pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
titleRes = R.string.pref_title_openkeystore_clear_keyid
visible = activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() ?: false
onClick {
activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
visible = false
true
}
}
val deleteRepoPref =
pref(PreferenceKeys.GIT_DELETE_REPO) {
titleRes = R.string.pref_git_delete_repo_title
summaryRes = R.string.pref_git_delete_repo_summary
visible = !activity.sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
onClick {
val repoDir = PasswordRepository.getRepositoryDirectory()
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.pref_dialog_delete_title)
.setMessage(activity.getString(R.string.dialog_delete_msg, repoDir))
.setCancelable(false)
.setPositiveButton(R.string.dialog_delete) { dialogInterface, _ ->
runCatching {
PasswordRepository.getRepositoryDirectory().deleteRecursively()
PasswordRepository.closeRepository()
}
.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() {
private val miscSettings = MiscSettings(this)
private val autofillSettings = AutofillSettings(this)
private val passwordSettings = PasswordSettings(this)
private val repositorySettings = RepositorySettings(this)
private val generalSettings = GeneralSettings(this)
private val miscSettings = MiscSettings(this)
private val autofillSettings = AutofillSettings(this)
private val passwordSettings = PasswordSettings(this)
private val repositorySettings = RepositorySettings(this)
private val generalSettings = GeneralSettings(this)
private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
private val preferencesAdapter: PreferencesAdapter
get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
private val preferencesAdapter: PreferencesAdapter
get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
val screen = screen(this) {
subScreen {
titleRes = R.string.pref_category_general_title
iconRes = R.drawable.app_settings_alt_24px
generalSettings.provideSettings(this)
}
subScreen {
titleRes = R.string.pref_category_autofill_title
iconRes = R.drawable.ic_wysiwyg_24px
autofillSettings.provideSettings(this)
}
subScreen {
titleRes = R.string.pref_category_passwords_title
iconRes = R.drawable.ic_lock_open_24px
passwordSettings.provideSettings(this)
}
subScreen {
titleRes = R.string.pref_category_repository_title
iconRes = R.drawable.ic_call_merge_24px
repositorySettings.provideSettings(this)
}
subScreen {
titleRes = R.string.pref_category_misc_title
iconRes = R.drawable.ic_miscellaneous_services_24px
miscSettings.provideSettings(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
val screen =
screen(this) {
subScreen {
titleRes = R.string.pref_category_general_title
iconRes = R.drawable.app_settings_alt_24px
generalSettings.provideSettings(this)
}
val adapter = PreferencesAdapter(screen)
adapter.onScreenChangeListener = PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
supportActionBar?.title = if (!entering) {
getString(R.string.action_settings)
} else {
getString(subScreen.titleRes)
}
subScreen {
titleRes = R.string.pref_category_autofill_title
iconRes = R.drawable.ic_wysiwyg_24px
autofillSettings.provideSettings(this)
}
savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter")
?.let(adapter::loadSavedState)
binding.preferenceRecyclerView.adapter = adapter
}
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_passwords_title
iconRes = R.drawable.ic_lock_open_24px
passwordSettings.provideSettings(this)
}
}
subScreen {
titleRes = R.string.pref_category_repository_title
iconRes = R.drawable.ic_call_merge_24px
repositorySettings.provideSettings(this)
}
subScreen {
titleRes = R.string.pref_category_misc_title
iconRes = R.drawable.ic_miscellaneous_services_24px
miscSettings.provideSettings(this)
}
}
val adapter = PreferencesAdapter(screen)
adapter.onScreenChangeListener =
PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
supportActionBar?.title =
if (!entering) {
getString(R.string.action_settings)
} else {
getString(subScreen.titleRes)
}
}
savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter")?.let(adapter::loadSavedState)
binding.preferenceRecyclerView.adapter = adapter
}
override fun onBackPressed() {
if (!preferencesAdapter.goBack())
super.onBackPressed()
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putParcelable("adapter", preferencesAdapter.getSavedState())
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home ->
if (!preferencesAdapter.goBack()) {
super.onOptionsItemSelected(item)
} else {
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onBackPressed() {
if (!preferencesAdapter.goBack()) super.onBackPressed()
}
}

View file

@ -7,13 +7,9 @@ package dev.msfjarvis.aps.ui.settings
import de.Maxr1998.modernpreferences.PreferenceScreen
/**
* Used to generate a uniform API for all settings UI classes.
*/
/** Used to generate a uniform API for all settings UI classes. */
interface SettingsProvider {
/**
* Inserts the settings items for the class into the given [builder].
*/
fun provideSettings(builder: PreferenceScreen.Builder)
/** Inserts the settings items for the class into the given [builder]. */
fun provideSettings(builder: PreferenceScreen.Builder)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -18,61 +18,69 @@ import dev.msfjarvis.aps.R
object BiometricAuthenticator {
private const val TAG = "BiometricAuthenticator"
private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
private const val TAG = "BiometricAuthenticator"
private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
sealed class Result {
data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
data class Failure(val code: Int?, val message: CharSequence) : Result()
object HardwareUnavailableOrDisabled : Result()
object Cancelled : Result()
}
sealed class Result {
data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
data class Failure(val code: Int?, val message: CharSequence) : Result()
object HardwareUnavailableOrDisabled : Result()
object Cancelled : Result()
}
fun canAuthenticate(activity: FragmentActivity): Boolean {
return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
}
fun canAuthenticate(activity: FragmentActivity): Boolean {
return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(
activity: FragmentActivity,
@StringRes dialogTitleRes: Int = R.string.biometric_prompt_title,
callback: (Result) -> Unit
) {
val authCallback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" }
callback(when (errorCode) {
BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
Result.Cancelled
}
BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE,
BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
Result.HardwareUnavailableOrDisabled
}
else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString))
})
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error)))
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
callback(Result.Success(result.cryptoObject))
fun authenticate(
activity: FragmentActivity,
@StringRes dialogTitleRes: Int = R.string.biometric_prompt_title,
callback: (Result) -> Unit
) {
val authCallback =
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" }
callback(
when (errorCode) {
BiometricPrompt.ERROR_CANCELED,
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
Result.Cancelled
}
BiometricPrompt.ERROR_HW_NOT_PRESENT,
BiometricPrompt.ERROR_HW_UNAVAILABLE,
BiometricPrompt.ERROR_NO_BIOMETRICS,
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
Result.HardwareUnavailableOrDisabled
}
else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString))
}
)
}
val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true
if (canAuthenticate(activity) || deviceHasKeyguard) {
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle(activity.getString(dialogTitleRes))
.setAllowedAuthenticators(validAuthenticators)
.build()
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback).authenticate(promptInfo)
} else {
callback(Result.HardwareUnavailableOrDisabled)
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error)))
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
callback(Result.Success(result.cryptoObject))
}
}
val deviceHasKeyguard = activity.getSystemService<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 java.io.File
/**
* Implements [AutofillResponseBuilder]'s methods for API 30 and above
*/
/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
@RequiresApi(Build.VERSION_CODES.R)
class Api30AutofillResponseBuilder(form: FillableForm) {
private val formOrigin = form.formOrigin
private val scenario = form.scenario
private val ignoredIds = form.ignoredIds
private val saveFlags = form.saveFlags
private val clientState = form.toClientState()
private val formOrigin = form.formOrigin
private val scenario = form.scenario
private val ignoredIds = form.ignoredIds
private val saveFlags = form.saveFlags
private val clientState = form.toClientState()
// We do not offer save when the only relevant field is a username field or there is no field.
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
private val canBeSaved = saveFlags != null && scenarioSupportsSave
// We do not offer save when the only relevant field is a username field or there is no field.
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
private val canBeSaved = saveFlags != null && scenarioSupportsSave
private fun makeIntentDataset(
context: Context,
action: AutofillAction,
intentSender: IntentSender,
metadata: DatasetMetadata,
imeSpec: InlinePresentationSpec?,
): Dataset {
return Dataset.Builder(makeRemoteView(context, metadata)).run {
fillWith(scenario, action, credentials = null)
setAuthentication(intentSender)
if (imeSpec != null) {
val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata)
if (inlinePresentation != null) {
setInlinePresentation(inlinePresentation)
}
}
build()
private fun makeIntentDataset(
context: Context,
action: AutofillAction,
intentSender: IntentSender,
metadata: DatasetMetadata,
imeSpec: InlinePresentationSpec?,
): Dataset {
return Dataset.Builder(makeRemoteView(context, metadata)).run {
fillWith(scenario, action, credentials = null)
setAuthentication(intentSender)
if (imeSpec != null) {
val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata)
if (inlinePresentation != null) {
setInlinePresentation(inlinePresentation)
}
}
build()
}
}
private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
val metadata = makeFillMatchMetadata(context, file)
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
val metadata = makeFillMatchMetadata(context, file)
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
}
private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
val metadata = makeSearchAndFillMetadata(context)
val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec)
}
private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
val metadata = makeGenerateAndFillMetadata(context)
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec)
}
private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
val metadata = makeFillOtpFromSmsMetadata(context)
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec)
}
private fun makePublisherChangedDataset(
context: Context,
publisherChangedException: AutofillPublisherChangedException,
imeSpec: InlinePresentationSpec?
): Dataset {
val metadata = makeWarningMetadata(context)
// If the user decides to trust the new publisher, they can choose reset the list of
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
val fillResponseAfterReset = makeFillResponse(context, null, emptyList())
val intentSender =
AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
context,
publisherChangedException,
fillResponseAfterReset
)
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
}
private fun makePublisherChangedResponse(
context: Context,
inlineSuggestionsRequest: InlineSuggestionsRequest?,
publisherChangedException: AutofillPublisherChangedException
): FillResponse {
val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull()
return FillResponse.Builder().run {
addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec))
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
}
private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
val metadata = makeSearchAndFillMetadata(context)
val intentSender =
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec)
}
private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
val metadata = makeGenerateAndFillMetadata(context)
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec)
}
private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
val metadata = makeFillOtpFromSmsMetadata(context)
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec)
}
private fun makePublisherChangedDataset(
context: Context,
publisherChangedException: AutofillPublisherChangedException,
imeSpec: InlinePresentationSpec?
): Dataset {
val metadata = makeWarningMetadata(context)
// If the user decides to trust the new publisher, they can choose reset the list of
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
val fillResponseAfterReset = makeFillResponse(context, null, emptyList())
val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
context, publisherChangedException, fillResponseAfterReset
)
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
}
private fun makePublisherChangedResponse(
context: Context,
inlineSuggestionsRequest: InlineSuggestionsRequest?,
publisherChangedException: AutofillPublisherChangedException
): FillResponse {
val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull()
return FillResponse.Builder().run {
addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec))
setIgnoredIds(*ignoredIds.toTypedArray())
build()
private fun makeFillResponse(
context: Context,
inlineSuggestionsRequest: InlineSuggestionsRequest?,
matchedFiles: List<File>
): FillResponse? {
var datasetCount = 0
val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
return FillResponse.Builder().run {
for (file in matchedFiles) {
makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
datasetCount++
addDataset(it)
}
}
makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
datasetCount++
addDataset(it)
}
makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
datasetCount++
addDataset(it)
}
makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
datasetCount++
addDataset(it)
}
if (datasetCount == 0) return null
setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
makeSaveInfo()?.let { setSaveInfo(it) }
setClientState(clientState)
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
}
private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List<File>): FillResponse? {
var datasetCount = 0
val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
return FillResponse.Builder().run {
for (file in matchedFiles) {
makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
datasetCount++
addDataset(it)
}
}
makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
datasetCount++
addDataset(it)
}
makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
datasetCount++
addDataset(it)
}
makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
datasetCount++
addDataset(it)
}
if (datasetCount == 0) return null
setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
makeSaveInfo()?.let { setSaveInfo(it) }
setClientState(clientState)
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
// See:
// https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
private fun makeSaveInfo(): SaveInfo? {
if (!canBeSaved) return null
check(saveFlags != null)
val idsToSave = scenario.fieldsToSave.toTypedArray()
if (idsToSave.isEmpty()) return null
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
if (scenario.hasUsername) {
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
}
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
setFlags(saveFlags)
build()
}
}
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
// See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
private fun makeSaveInfo(): SaveInfo? {
if (!canBeSaved) return null
check(saveFlags != null)
val idsToSave = scenario.fieldsToSave.toTypedArray()
if (idsToSave.isEmpty()) return null
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
if (scenario.hasUsername) {
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
/** Creates and returns a suitable [FillResponse] to the Autofill framework. */
fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) {
AutofillMatcher.getMatchesFor(context, formOrigin)
.fold(
success = { matchedFiles ->
callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
},
failure = { e ->
e(e)
callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e))
}
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
setFlags(saveFlags)
build()
}
}
/**
* Creates and returns a suitable [FillResponse] to the Autofill framework.
*/
fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) {
AutofillMatcher.getMatchesFor(context, formOrigin).fold(
success = { matchedFiles ->
callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
},
failure = { e ->
e(e)
callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e))
}
)
}
)
}
}

View file

@ -21,173 +21,165 @@ import java.io.File
private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches"
private val Context.autofillAppMatches
get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches"
private val Context.autofillWebMatches
get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences {
return when (formOrigin) {
is FormOrigin.App -> autofillAppMatches
is FormOrigin.Web -> autofillWebMatches
}
return when (formOrigin) {
is FormOrigin.App -> autofillAppMatches
is FormOrigin.Web -> autofillWebMatches
}
}
class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
init {
require(formOrigin is FormOrigin.App)
}
init {
require(formOrigin is FormOrigin.App)
}
}
/**
* Manages "matches", i.e., associations between apps or websites and Password Store entries.
*/
/** Manages "matches", i.e., associations between apps or websites and Password Store entries. */
class AutofillMatcher {
companion object {
companion object {
private const val MAX_NUM_MATCHES = 10
private const val MAX_NUM_MATCHES = 10
private const val PREFERENCE_PREFIX_TOKEN = "token;"
private fun tokenKey(formOrigin: FormOrigin.App) =
"$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
private const val PREFERENCE_PREFIX_TOKEN = "token;"
private fun tokenKey(formOrigin: FormOrigin.App) = "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
private const val PREFERENCE_PREFIX_MATCHES = "matches;"
private fun matchesKey(formOrigin: FormOrigin) =
"$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
private const val PREFERENCE_PREFIX_MATCHES = "matches;"
private fun matchesKey(formOrigin: FormOrigin) = "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
return when (formOrigin) {
is FormOrigin.Web -> false
is FormOrigin.App -> {
val packageName = formOrigin.identifier
val certificatesHash = computeCertificatesHash(context, packageName)
val storedCertificatesHash =
context.autofillAppMatches.getString(tokenKey(formOrigin), null)
?: return false
val hashHasChanged = certificatesHash != storedCertificatesHash
if (hashHasChanged) {
e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
true
} else {
false
}
}
}
}
private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
if (formOrigin is FormOrigin.App) {
val packageName = formOrigin.identifier
val certificatesHash = computeCertificatesHash(context, packageName)
context.autofillAppMatches.edit {
putString(tokenKey(formOrigin), certificatesHash)
}
}
// We don't need to store a hash for FormOrigin.Web since it can only originate from
// browsers we trust to verify the origin.
}
/**
* Get all Password Store entries that have already been associated with [formOrigin] by the
* user.
*
* If [formOrigin] represents an app and that app's certificates have changed since the
* first time the user associated an entry with it, an [AutofillPublisherChangedException]
* will be thrown.
*/
fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<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 hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
return when (formOrigin) {
is FormOrigin.Web -> false
is FormOrigin.App -> {
val packageName = formOrigin.identifier
val certificatesHash = computeCertificatesHash(context, packageName)
val storedCertificatesHash = context.autofillAppMatches.getString(tokenKey(formOrigin), null) ?: return false
val hashHasChanged = certificatesHash != storedCertificatesHash
if (hashHasChanged) {
e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
true
} else {
false
}
}
}
}
private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
if (formOrigin is FormOrigin.App) {
val packageName = formOrigin.identifier
val certificatesHash = computeCertificatesHash(context, packageName)
context.autofillAppMatches.edit { putString(tokenKey(formOrigin), certificatesHash) }
}
// We don't need to store a hash for FormOrigin.Web since it can only originate from
// browsers we trust to verify the origin.
}
/**
* Get all Password Store entries that have already been associated with [formOrigin] by the
* user.
*
* If [formOrigin] represents an app and that app's certificates have changed since the first
* time the user associated an entry with it, an [AutofillPublisherChangedException] will be
* thrown.
*/
fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<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
enum class DirectoryStructure(val value: String) {
EncryptedUsername("encrypted_username"),
FileBased("file"),
DirectoryBased("directory");
EncryptedUsername("encrypted_username"),
FileBased("file"),
DirectoryBased("directory");
/**
* Returns the username associated to [file], following the convention of the current
* [DirectoryStructure].
*
* Examples:
* - * --> null (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
* - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
*/
fun getUsernameFor(file: File): String? = when (this) {
EncryptedUsername -> null
FileBased -> file.nameWithoutExtension
DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
/**
* Returns the username associated to [file], following the convention of the current
* [DirectoryStructure].
*
* Examples:
* - * --> null (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
* - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
*/
fun getUsernameFor(file: File): String? =
when (this) {
EncryptedUsername -> null
FileBased -> file.nameWithoutExtension
DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
}
/**
* Returns the origin identifier associated to [file], following the convention of the current
* [DirectoryStructure].
*
* At least one of [DirectoryStructure.getIdentifierFor] and
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
*
* Examples:
* - work/example.org.gpg --> example.org (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> example.org (FileBased)
* - example.org.gpg --> example.org (FileBased, fallback)
* - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
* - Temporary PIN.gpg --> null (DirectoryBased)
*/
fun getIdentifierFor(file: File): String? = when (this) {
EncryptedUsername -> file.nameWithoutExtension
FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
DirectoryBased -> file.parentFile?.parent
/**
* Returns the origin identifier associated to [file], following the convention of the current
* [DirectoryStructure].
*
* At least one of [DirectoryStructure.getIdentifierFor] and
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
*
* Examples:
* - work/example.org.gpg --> example.org (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> example.org (FileBased)
* - example.org.gpg --> example.org (FileBased, fallback)
* - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
* - Temporary PIN.gpg --> null (DirectoryBased)
*/
fun getIdentifierFor(file: File): String? =
when (this) {
EncryptedUsername -> file.nameWithoutExtension
FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
DirectoryBased -> file.parentFile?.parent
}
/**
* Returns the path components of [file] until right before the component that contains the
* origin identifier according to the current [DirectoryStructure].
*
* Examples:
* - work/example.org.gpg --> work (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> work (FileBased)
* - example.org/john@doe.org.gpg --> null (FileBased)
* - john@doe.org.gpg --> null (FileBased)
* - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
* - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
*/
fun getPathToIdentifierFor(file: File): String? = when (this) {
EncryptedUsername -> file.parent
FileBased -> file.parentFile?.parent
DirectoryBased -> file.parentFile?.parentFile?.parent
/**
* Returns the path components of [file] until right before the component that contains the origin
* identifier according to the current [DirectoryStructure].
*
* Examples:
* - work/example.org.gpg --> work (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> work (FileBased)
* - example.org/john@doe.org.gpg --> null (FileBased)
* - john@doe.org.gpg --> null (FileBased)
* - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
* - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
*/
fun getPathToIdentifierFor(file: File): String? =
when (this) {
EncryptedUsername -> file.parent
FileBased -> file.parentFile?.parent
DirectoryBased -> file.parentFile?.parentFile?.parent
}
/**
* Returns the path component of [file] following the origin identifier according to the current
* [DirectoryStructure] (without file extension).
*
* At least one of [DirectoryStructure.getIdentifierFor] and
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
*
* Examples:
* - * --> null (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
* - example.org.gpg --> null (FileBased, fallback)
* - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
*/
fun getAccountPartFor(file: File): String? = when (this) {
EncryptedUsername -> null
FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
DirectoryBased -> file.parentFile?.let { parentFile ->
"${parentFile.name}/${file.nameWithoutExtension}"
} ?: file.nameWithoutExtension
/**
* Returns the path component of [file] following the origin identifier according to the current
* [DirectoryStructure](without file extension).
*
* At least one of [DirectoryStructure.getIdentifierFor] and
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
*
* Examples:
* - * --> null (EncryptedUsername)
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
* - example.org.gpg --> null (FileBased, fallback)
* - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
*/
fun getAccountPartFor(file: File): String? =
when (this) {
EncryptedUsername -> null
FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
DirectoryBased -> file.parentFile?.let { parentFile -> "${parentFile.name}/${file.nameWithoutExtension}" }
?: file.nameWithoutExtension
}
@RequiresApi(Build.VERSION_CODES.O)
fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) {
EncryptedUsername -> "/"
FileBased -> sanitizedIdentifier
DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
@RequiresApi(Build.VERSION_CODES.O)
fun getSaveFolderName(sanitizedIdentifier: String, username: String?) =
when (this) {
EncryptedUsername -> "/"
FileBased -> sanitizedIdentifier
DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
}
fun getSaveFileName(username: String?, identifier: String) = when (this) {
EncryptedUsername -> identifier
FileBased -> username
DirectoryBased -> "password"
fun getSaveFileName(username: String?, identifier: String) =
when (this) {
EncryptedUsername -> identifier
FileBased -> username
DirectoryBased -> "password"
}
companion object {
companion object {
val DEFAULT = FileBased
val DEFAULT = FileBased
private val reverseMap = values().associateBy { it.value }
fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
}
private val reverseMap = values().associateBy { it.value }
fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
}
}
object AutofillPreferences {
fun directoryStructure(context: Context): DirectoryStructure {
val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
return DirectoryStructure.fromValue(value)
}
fun directoryStructure(context: Context): DirectoryStructure {
val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
return DirectoryStructure.fromValue(value)
}
fun credentialsFromStoreEntry(
context: Context,
file: File,
entry: PasswordEntry,
directoryStructure: DirectoryStructure
): Credentials {
// Always give priority to a username stored in the encrypted extras
val username = entry.username
?: directoryStructure.getUsernameFor(file)
?: context.getDefaultUsername()
return Credentials(username, entry.password, entry.calculateTotpCode())
}
fun credentialsFromStoreEntry(
context: Context,
file: File,
entry: PasswordEntry,
directoryStructure: DirectoryStructure
): Credentials {
// Always give priority to a username stored in the encrypted extras
val username = entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
return Credentials(username, entry.password, entry.calculateTotpCode())
}
}

View file

@ -30,176 +30,178 @@ import java.io.File
@RequiresApi(Build.VERSION_CODES.O)
class AutofillResponseBuilder(form: FillableForm) {
private val formOrigin = form.formOrigin
private val scenario = form.scenario
private val ignoredIds = form.ignoredIds
private val saveFlags = form.saveFlags
private val clientState = form.toClientState()
private val formOrigin = form.formOrigin
private val scenario = form.scenario
private val ignoredIds = form.ignoredIds
private val saveFlags = form.saveFlags
private val clientState = form.toClientState()
// We do not offer save when the only relevant field is a username field or there is no field.
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
private val canBeSaved = saveFlags != null && scenarioSupportsSave
// We do not offer save when the only relevant field is a username field or there is no field.
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
private val canBeSaved = saveFlags != null && scenarioSupportsSave
private fun makeIntentDataset(
context: Context,
action: AutofillAction,
intentSender: IntentSender,
metadata: DatasetMetadata,
): Dataset {
return Dataset.Builder(makeRemoteView(context, metadata)).run {
fillWith(scenario, action, credentials = null)
setAuthentication(intentSender)
build()
private fun makeIntentDataset(
context: Context,
action: AutofillAction,
intentSender: IntentSender,
metadata: DatasetMetadata,
): Dataset {
return Dataset.Builder(makeRemoteView(context, metadata)).run {
fillWith(scenario, action, credentials = null)
setAuthentication(intentSender)
build()
}
}
private fun makeMatchDataset(context: Context, file: File): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
val metadata = makeFillMatchMetadata(context, file)
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
}
private fun makeSearchDataset(context: Context): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
val metadata = makeSearchAndFillMetadata(context)
val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
}
private fun makeGenerateDataset(context: Context): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
val metadata = makeGenerateAndFillMetadata(context)
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata)
}
private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
val metadata = makeFillOtpFromSmsMetadata(context)
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata)
}
private fun makePublisherChangedDataset(
context: Context,
publisherChangedException: AutofillPublisherChangedException,
): Dataset {
val metadata = makeWarningMetadata(context)
// If the user decides to trust the new publisher, they can choose reset the list of
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
val fillResponseAfterReset = makeFillResponse(context, emptyList())
val intentSender =
AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
context,
publisherChangedException,
fillResponseAfterReset
)
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
}
private fun makePublisherChangedResponse(
context: Context,
publisherChangedException: AutofillPublisherChangedException
): FillResponse {
return FillResponse.Builder().run {
addDataset(makePublisherChangedDataset(context, publisherChangedException))
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
}
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
// See:
// https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
private fun makeSaveInfo(): SaveInfo? {
if (!canBeSaved) return null
check(saveFlags != null)
val idsToSave = scenario.fieldsToSave.toTypedArray()
if (idsToSave.isEmpty()) return null
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
if (scenario.hasUsername) {
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
}
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
setFlags(saveFlags)
build()
}
}
private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
var datasetCount = 0
return FillResponse.Builder().run {
for (file in matchedFiles) {
makeMatchDataset(context, file)?.let {
datasetCount++
addDataset(it)
}
}
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
}
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)))
)
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
}
makeSaveInfo()?.let { setSaveInfo(it) }
setClientState(clientState)
setIgnoredIds(*ignoredIds.toTypedArray())
build()
}
}
private fun makePublisherChangedResponse(
context: Context,
publisherChangedException: AutofillPublisherChangedException
): FillResponse {
return FillResponse.Builder().run {
addDataset(makePublisherChangedDataset(context, publisherChangedException))
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))
}
}
)
}
// 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 {
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()
}
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()
}
}
}
}

View file

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

View file

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

View file

@ -34,146 +34,115 @@ import dev.msfjarvis.aps.data.repo.PasswordRepository
import dev.msfjarvis.aps.util.git.operation.GitOperation
/**
* Extension function for [AlertDialog] that requests focus for the
* view whose id is [id]. Solution based on a StackOverflow
* answer: https://stackoverflow.com/a/13056259/297261
* Extension function for [AlertDialog] that requests focus for the view whose id is [id]. Solution
* based on a StackOverflow answer: https://stackoverflow.com/a/13056259/297261
*/
fun <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) {
setOnShowListener {
findViewById<T>(id)?.apply {
setOnFocusChangeListener { v, _ ->
v.post {
context.getSystemService<InputMethodManager>()
?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT)
}
}
requestFocus()
}
setOnShowListener {
findViewById<T>(id)?.apply {
setOnFocusChangeListener { v, _ ->
v.post { context.getSystemService<InputMethodManager>()?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) }
}
requestFocus()
}
}
}
/**
* Get an instance of [AutofillManager]. Only
* available on Android Oreo and above
*/
/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */
val Context.autofillManager: AutofillManager?
@RequiresApi(Build.VERSION_CODES.O)
get() = getSystemService()
@RequiresApi(Build.VERSION_CODES.O) get() = getSystemService()
/**
* Get an instance of [ClipboardManager]
*/
/** Get an instance of [ClipboardManager] */
val Context.clipboard
get() = getSystemService<ClipboardManager>()
get() = getSystemService<ClipboardManager>()
/**
* Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at
* each call site
*/
/** Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at each call site */
fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation")
/**
* Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP
* proxy.
*/
/** Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP proxy. */
fun Context.getEncryptedProxyPrefs() = getEncryptedPrefs("http_proxy")
/**
* Get an instance of [EncryptedSharedPreferences] with the given [fileName]
*/
/** Get an instance of [EncryptedSharedPreferences] with the given [fileName] */
private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences {
val masterKeyAlias = MasterKey.Builder(applicationContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
return EncryptedSharedPreferences.create(
applicationContext,
fileName,
masterKeyAlias,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
val masterKeyAlias = MasterKey.Builder(applicationContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
return EncryptedSharedPreferences.create(
applicationContext,
fileName,
masterKeyAlias,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
/**
* Get an instance of [KeyguardManager]
*/
/** Get an instance of [KeyguardManager] */
val Context.keyguardManager: KeyguardManager
get() = getSystemService()!!
get() = getSystemService()!!
/**
* Get the default [SharedPreferences] instance
*/
/** Get the default [SharedPreferences] instance */
val Context.sharedPrefs: SharedPreferences
get() = PreferenceManager.getDefaultSharedPreferences(applicationContext)
get() = PreferenceManager.getDefaultSharedPreferences(applicationContext)
/**
* Resolve [attr] from the [Context]'s theme
*/
/** Resolve [attr] from the [Context]'s theme */
fun Context.resolveAttribute(attr: Int): Int {
val typedValue = TypedValue()
this.theme.resolveAttribute(attr, typedValue, true)
return typedValue.data
val typedValue = TypedValue()
this.theme.resolveAttribute(attr, typedValue, true)
return typedValue.data
}
/**
* Commit changes to the store from a [FragmentActivity] using
* a custom implementation of [GitOperation]
* Commit changes to the store from a [FragmentActivity] using a custom implementation of
* [GitOperation]
*/
suspend fun FragmentActivity.commitChange(
message: String,
message: String,
): Result<Unit, Throwable> {
if (!PasswordRepository.isGitRepo()) {
return Ok(Unit)
}
return object : GitOperation(this@commitChange) {
override val commands = arrayOf(
// Stage all files
git.add().addFilepattern("."),
// Populate the changed files count
git.status(),
// Commit everything! If anything changed, that is.
git.commit().setAll(true).setMessage(message),
if (!PasswordRepository.isGitRepo()) {
return Ok(Unit)
}
return object : GitOperation(this@commitChange) {
override val commands =
arrayOf(
// Stage all files
git.add().addFilepattern("."),
// Populate the changed files count
git.status(),
// Commit everything! If anything changed, that is.
git.commit().setAll(true).setMessage(message),
)
override fun preExecute(): Boolean {
d { "Committing with message: '$message'" }
return true
}
}.execute()
override fun preExecute(): Boolean {
d { "Committing with message: '$message'" }
return true
}
}
.execute()
}
/**
* Check if [permission] has been granted to the app.
*/
/** Check if [permission] has been granted to the app. */
fun FragmentActivity.isPermissionGranted(permission: String): Boolean {
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}
/**
* Show a [Snackbar] in a [FragmentActivity] and correctly
* anchor it to a [com.google.android.material.floatingactionbutton.FloatingActionButton]
* if one exists in the [view]
* Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a
* [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the
* [view]
*/
fun FragmentActivity.snackbar(
view: View = findViewById(android.R.id.content),
message: String,
length: Int = Snackbar.LENGTH_SHORT,
view: View = findViewById(android.R.id.content),
message: String,
length: Int = Snackbar.LENGTH_SHORT,
): Snackbar {
val snackbar = Snackbar.make(view, message, length)
snackbar.anchorView = findViewById(R.id.fab)
snackbar.show()
return snackbar
val snackbar = Snackbar.make(view, message, length)
snackbar.anchorView = findViewById(R.id.fab)
snackbar.show()
return snackbar
}
/**
* Simplifies the common `getString(key, null) ?: defaultValue` case slightly
*/
/** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */
fun SharedPreferences.getString(key: String): String? = getString(key, null)
/**
* Convert this [String] to its [Base64] representation
*/
/** Convert this [String] to its [Base64] representation */
fun String.base64(): String {
return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP)
return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP)
}

View file

@ -12,53 +12,40 @@ import java.util.Date
import org.eclipse.jgit.lib.ObjectId
import org.eclipse.jgit.revwalk.RevCommit
/**
* The default OpenPGP provider for the app
*/
/** The default OpenPGP provider for the app */
const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
/**
* Clears the given [flag] from the value of this [Int]
*/
/** Clears the given [flag] from the value of this [Int] */
fun Int.clearFlag(flag: Int): Int {
return this and flag.inv()
return this and flag.inv()
}
/**
* Checks if this [Int] contains the given [flag]
*/
/** Checks if this [Int] contains the given [flag] */
infix fun Int.hasFlag(flag: Int): Boolean {
return this and flag == flag
return this and flag == flag
}
/**
* Checks whether this [File] is a directory that contains [other].
*/
/** Checks whether this [File] is a directory that contains [other]. */
fun File.contains(other: File): Boolean {
if (!isDirectory)
return false
if (!other.exists())
return false
val relativePath = runCatching {
other.relativeTo(this)
}.getOrElse {
return false
if (!isDirectory) return false
if (!other.exists()) return false
val relativePath =
runCatching { other.relativeTo(this) }.getOrElse {
return false
}
// Direct containment is equivalent to the relative path being equal to the filename.
return relativePath.path == other.name
// Direct containment is equivalent to the relative path being equal to the filename.
return relativePath.path == other.name
}
/**
* Checks if this [File] is in the password repository directory as given
* by [PasswordRepository.getRepositoryDirectory]
* Checks if this [File] is in the password repository directory as given by
* [PasswordRepository.getRepositoryDirectory]
*/
fun File.isInsideRepository(): Boolean {
return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath)
return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath)
}
/**
* Recursively lists the files in this [File], skipping any directories it encounters.
*/
/** Recursively lists the files in this [File], skipping any directories it encounters. */
fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList()
/**
@ -67,7 +54,7 @@ fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toLis
* @see RevCommit.getId
*/
val RevCommit.hash: String
get() = ObjectId.toString(id)
get() = ObjectId.toString(id)
/**
* Time this commit was made with second precision.
@ -75,16 +62,16 @@ val RevCommit.hash: String
* @see RevCommit.commitTime
*/
val RevCommit.time: Date
get() {
val epochSeconds = commitTime.toLong()
val epochMilliseconds = epochSeconds * 1000
return Date(epochMilliseconds)
}
get() {
val epochSeconds = commitTime.toLong()
val epochMilliseconds = epochSeconds * 1000
return Date(epochMilliseconds)
}
/**
* Splits this [String] into an [Array] of [String]s, split on the UNIX LF line ending
* and stripped of any empty lines.
* Splits this [String] into an [Array] of [String] s, split on the UNIX LF line ending and stripped
* of any empty lines.
*/
fun String.splitLines(): Array<String> {
return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
}

View file

@ -11,31 +11,31 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.commit
import dev.msfjarvis.aps.R
/**
* Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally.
*/
/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */
fun Fragment.isPermissionGranted(permission: String): Boolean {
return requireActivity().isPermissionGranted(permission)
return requireActivity().isPermissionGranted(permission)
}
/**
* Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity]
*/
/** Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] */
fun Fragment.finish() = requireActivity().finish()
/**
* Perform a [commit] on this [FragmentManager] with custom animations and adding the [destinationFragment]
* to the fragment backstack
* Perform a [commit] on this [FragmentManager] with custom animations and adding the
* [destinationFragment] to the fragment backstack
*/
fun FragmentManager.performTransactionWithBackStack(destinationFragment: Fragment, @IdRes containerViewId: Int = android.R.id.content) {
commit {
beginTransaction()
addToBackStack(destinationFragment.tag)
setCustomAnimations(
R.animator.slide_in_left,
R.animator.slide_out_left,
R.animator.slide_in_right,
R.animator.slide_out_right)
replace(containerViewId, destinationFragment)
}
fun FragmentManager.performTransactionWithBackStack(
destinationFragment: Fragment,
@IdRes containerViewId: Int = android.R.id.content
) {
commit {
beginTransaction()
addToBackStack(destinationFragment.tag)
setCustomAnimations(
R.animator.slide_in_left,
R.animator.slide_out_left,
R.animator.slide_in_right,
R.animator.slide_out_right
)
replace(containerViewId, destinationFragment)
}
}

View file

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

View file

@ -12,57 +12,54 @@ import dev.msfjarvis.aps.R
import java.net.UnknownHostException
/**
* Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute].
* Supertype for all Git-related [Exception] s that can be thrown by [GitCommandExecutor.execute].
*/
sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) {
override val message = super.message!!
override val message = super.message!!
companion object {
companion object {
private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt)
}
private fun buildMessage(@StringRes res: Int, vararg fmt: String) =
Application.instance.resources.getString(res, *fmt)
}
/**
* Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand].
*/
sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
/** Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. */
sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error)
object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error)
}
object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error)
object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error)
}
/**
* Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand].
*/
sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
/** Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. */
sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
object NonFastForward : PushException(R.string.git_push_nff_error)
object RemoteRejected : PushException(R.string.git_push_other_error)
class Generic(message: String) : PushException(R.string.git_push_generic_error, message)
}
object NonFastForward : PushException(R.string.git_push_nff_error)
object RemoteRejected : PushException(R.string.git_push_other_error)
class Generic(message: String) : PushException(R.string.git_push_generic_error, message)
}
}
object ErrorMessages {
operator fun get(throwable: Throwable?): String {
val resources = Application.instance.resources
if (throwable == null) return resources.getString(R.string.git_unknown_error)
return when (val rootCause = rootCause(throwable)) {
is GitException -> rootCause.message
is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message)
else -> throwable.message ?: resources.getString(R.string.git_unknown_error)
}
operator fun get(throwable: Throwable?): String {
val resources = Application.instance.resources
if (throwable == null) return resources.getString(R.string.git_unknown_error)
return when (val rootCause = rootCause(throwable)) {
is GitException -> rootCause.message
is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message)
else -> throwable.message ?: resources.getString(R.string.git_unknown_error)
}
}
private fun rootCause(throwable: Throwable): Throwable {
var cause = throwable
while (cause.cause != null) {
if (cause is GitException) break
val nextCause = cause.cause!!
if (nextCause is RemoteException) break
cause = nextCause
}
return cause
private fun rootCause(throwable: Throwable): Throwable {
var cause = throwable
while (cause.cause != null) {
if (cause is GitException) break
val nextCause = cause.cause!!
if (nextCause is RemoteException) break
cause = nextCause
}
return cause
}
}

View file

@ -26,96 +26,87 @@ import org.eclipse.jgit.lib.PersonIdent
import org.eclipse.jgit.transport.RemoteRefUpdate
class GitCommandExecutor(
private val activity: FragmentActivity,
private val operation: GitOperation,
private val activity: FragmentActivity,
private val operation: GitOperation,
) {
suspend fun execute(): Result<Unit, Throwable> {
val snackbar = activity.snackbar(
message = activity.resources.getString(R.string.git_operation_running),
length = Snackbar.LENGTH_INDEFINITE,
)
// Count the number of uncommitted files
var nbChanges = 0
return runCatching {
for (command in operation.commands) {
when (command) {
is StatusCommand -> {
val res = withContext(Dispatchers.IO) {
command.call()
}
nbChanges = res.uncommittedChanges.size
}
is CommitCommand -> {
// the previous status will eventually be used to avoid a commit
if (nbChanges > 0) {
withContext(Dispatchers.IO) {
val name = GitSettings.authorName.ifEmpty { "root" }
val email = GitSettings.authorEmail.ifEmpty { "localhost" }
val identity = PersonIdent(name, email)
command.setAuthor(identity).setCommitter(identity).call()
}
}
}
is PullCommand -> {
val result = withContext(Dispatchers.IO) {
command.call()
}
if (result.rebaseResult != null) {
if (!result.rebaseResult.status.isSuccessful) {
throw PullException.PullRebaseFailed
}
} else if (result.mergeResult != null) {
if (!result.mergeResult.mergeStatus.isSuccessful) {
throw PullException.PullMergeFailed
}
}
}
is PushCommand -> {
val results = withContext(Dispatchers.IO) {
command.call()
}
for (result in results) {
// Code imported (modified) from Gerrit PushOp, license Apache v2
for (rru in result.remoteUpdates) {
when (rru.status) {
RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward
RemoteRefUpdate.Status.REJECTED_NODELETE,
RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
RemoteRefUpdate.Status.NON_EXISTING,
RemoteRefUpdate.Status.NOT_ATTEMPTED,
-> throw PushException.Generic(rru.status.name)
RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
throw if ("non-fast-forward" == rru.message) {
PushException.RemoteRejected
} else {
PushException.Generic(rru.message)
}
}
RemoteRefUpdate.Status.UP_TO_DATE -> {
withContext(Dispatchers.Main) {
Toast.makeText(
activity.applicationContext,
activity.applicationContext.getString(R.string.git_push_up_to_date),
Toast.LENGTH_SHORT
).show()
}
}
else -> {
}
}
}
}
}
else -> {
withContext(Dispatchers.IO) {
command.call()
}
}
}
suspend fun execute(): Result<Unit, Throwable> {
val snackbar =
activity.snackbar(
message = activity.resources.getString(R.string.git_operation_running),
length = Snackbar.LENGTH_INDEFINITE,
)
// Count the number of uncommitted files
var nbChanges = 0
return runCatching {
for (command in operation.commands) {
when (command) {
is StatusCommand -> {
val res = withContext(Dispatchers.IO) { command.call() }
nbChanges = res.uncommittedChanges.size
}
is CommitCommand -> {
// the previous status will eventually be used to avoid a commit
if (nbChanges > 0) {
withContext(Dispatchers.IO) {
val name = GitSettings.authorName.ifEmpty { "root" }
val email = GitSettings.authorEmail.ifEmpty { "localhost" }
val identity = PersonIdent(name, email)
command.setAuthor(identity).setCommitter(identity).call()
}
}
}.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
private fun commits(): Iterable<RevCommit> {
val repo = PasswordRepository.getRepository(null)
if (repo == null) {
e { "Could not access git repository" }
return listOf()
}
return runCatching {
Git(repo).log().call()
}.getOrElse { e ->
e(e) { "Failed to obtain git commits" }
listOf()
}
val repo = PasswordRepository.getRepository(null)
if (repo == null) {
e { "Could not access git repository" }
return listOf()
}
return runCatching { Git(repo).log().call() }.getOrElse { e ->
e(e) { "Failed to obtain git commits" }
listOf()
}
}
/**
* Provides [GitCommit]s from a git-log of the password git repository.
* Provides [GitCommit] s from a git-log of the password git repository.
*
* All commits are acquired on the first request to this object.
*/
class GitLogModel {
// All commits are acquired here at once. Acquiring the commits in batches would not have been
// entirely sensible because the amount of computation required to obtain commit number n from
// the log includes the amount of computation required to obtain commit number n-1 from the log.
// This is because the commit graph is walked from HEAD to the last commit to obtain.
// Additionally, tests with 1000 commits in the log have not produced a significant delay in the
// user experience.
private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) {
commits().map {
GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time)
}.toMutableList()
}
val size = cache.size
// All commits are acquired here at once. Acquiring the commits in batches would not have been
// entirely sensible because the amount of computation required to obtain commit number n from
// the log includes the amount of computation required to obtain commit number n-1 from the log.
// This is because the commit graph is walked from HEAD to the last commit to obtain.
// Additionally, tests with 1000 commits in the log have not produced a significant delay in the
// user experience.
private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) {
commits().map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }.toMutableList()
}
val size = cache.size
fun get(index: Int): GitCommit? {
if (index >= size) e { "Cannot get git commit with index $index. There are only $size." }
return cache.getOrNull(index)
}
fun get(index: Int): GitCommit? {
if (index >= size) e { "Cannot get git commit with index $index. There are only $size." }
return cache.getOrNull(index)
}
}

View file

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

View file

@ -24,80 +24,71 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
class CredentialFinder(
val callingActivity: FragmentActivity,
val authMode: AuthMode
) : InteractivePasswordFinder() {
class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) : InteractivePasswordFinder() {
override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
val gitOperationPrefs = callingActivity.getEncryptedGitPrefs()
val credentialPref: String
@StringRes val messageRes: Int
@StringRes val hintRes: Int
@StringRes val rememberRes: Int
@StringRes val errorRes: Int
when (authMode) {
AuthMode.SshKey -> {
credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
messageRes = R.string.passphrase_dialog_text
hintRes = R.string.ssh_keygen_passphrase
rememberRes = R.string.git_operation_remember_passphrase
errorRes = R.string.git_operation_wrong_passphrase
}
AuthMode.Password -> {
// Could be either an SSH or an HTTPS password
credentialPref = PreferenceKeys.HTTPS_PASSWORD
messageRes = R.string.password_dialog_text
hintRes = R.string.git_operation_hint_password
rememberRes = R.string.git_operation_remember_password
errorRes = R.string.git_operation_wrong_password
}
else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords")
}
if (isRetry)
gitOperationPrefs.edit { remove(credentialPref) }
val storedCredential = gitOperationPrefs.getString(credentialPref, null)
if (storedCredential == null) {
val layoutInflater = LayoutInflater.from(callingActivity)
@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)
}
override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
val gitOperationPrefs = callingActivity.getEncryptedGitPrefs()
val credentialPref: String
@StringRes val messageRes: Int
@StringRes val hintRes: Int
@StringRes val rememberRes: Int
@StringRes val errorRes: Int
when (authMode) {
AuthMode.SshKey -> {
credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
messageRes = R.string.passphrase_dialog_text
hintRes = R.string.ssh_keygen_passphrase
rememberRes = R.string.git_operation_remember_passphrase
errorRes = R.string.git_operation_wrong_passphrase
}
AuthMode.Password -> {
// Could be either an SSH or an HTTPS password
credentialPref = PreferenceKeys.HTTPS_PASSWORD
messageRes = R.string.password_dialog_text
hintRes = R.string.git_operation_hint_password
rememberRes = R.string.git_operation_remember_password
errorRes = R.string.git_operation_wrong_password
}
else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords")
}
if (isRetry) gitOperationPrefs.edit { remove(credentialPref) }
val storedCredential = gitOperationPrefs.getString(credentialPref, null)
if (storedCredential == null) {
val layoutInflater = LayoutInflater.from(callingActivity)
@SuppressLint("InflateParams") val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
editCredential.setHint(hintRes)
val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
rememberCredential.setText(rememberRes)
if (isRetry) {
credentialLayout.error = callingActivity.resources.getString(errorRes)
// Reset error when user starts entering a password
editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
}
MaterialAlertDialogBuilder(callingActivity)
.run {
setTitle(R.string.passphrase_dialog_title)
setMessage(messageRes)
setView(dialogView)
setPositiveButton(R.string.dialog_ok) { _, _ ->
val credential = editCredential.text.toString()
if (rememberCredential.isChecked) {
gitOperationPrefs.edit { putString(credentialPref, credential) }
}
cont.resume(credential)
}
setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) }
setOnCancelListener { cont.resume(null) }
create()
}
.run {
requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential)
show()
}
} else {
cont.resume(storedCredential)
}
}
}

View file

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

View file

@ -8,27 +8,28 @@ import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
import org.eclipse.jgit.api.GitCommand
class PullOperation(
callingActivity: ContinuationContainerActivity,
rebase: Boolean,
callingActivity: ContinuationContainerActivity,
rebase: Boolean,
) : GitOperation(callingActivity) {
/**
* The story of why the pull operation is committing files goes like this: Once upon a time when
* the world was burning and Blade Runner 2049 was real life (in the worst way), we were made
* aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing.
* So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation]
* and then a [PushOperation]. To make the behavior identical despite this suboptimal situation,
* we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
* replicating [SyncOperation] but leaving the pushing part to [PushOperation].
*/
override val commands: Array<GitCommand<out Any>> = arrayOf(
// Stage all files
git.add().addFilepattern("."),
// Populate the changed files count
git.status(),
// Commit everything! If needed, obviously.
git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
// Pull and rebase on top of the remote branch
git.pull().setRebase(rebase).setRemote("origin"),
/**
* The story of why the pull operation is committing files goes like this: Once upon a time when
* the world was burning and Blade Runner 2049 was real life (in the worst way), we were made
* aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing.
* So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation]
* and then a [PushOperation]. To make the behavior identical despite this suboptimal situation,
* we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
* replicating [SyncOperation] but leaving the pushing part to [PushOperation].
*/
override val commands: Array<GitCommand<out Any>> =
arrayOf(
// Stage all files
git.add().addFilepattern("."),
// Populate the changed files count
git.status(),
// Commit everything! If needed, obviously.
git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
// Pull and rebase on top of the remote branch
git.pull().setRebase(rebase).setRemote("origin"),
)
}

View file

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

View file

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

View file

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

View file

@ -14,24 +14,21 @@ import kotlin.coroutines.resumeWithException
import net.schmizz.sshj.common.DisconnectReason
import net.schmizz.sshj.userauth.UserAuthException
/**
* Workaround for https://msfjarvis.dev/aps/issue/1164
*/
/** Workaround for https://msfjarvis.dev/aps/issue/1164 */
open class ContinuationContainerActivity : AppCompatActivity {
constructor() : super()
constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
constructor() : super()
constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
var stashedCont: Continuation<Intent>? = null
var stashedCont: Continuation<Intent>? = null
val continueAfterUserInteraction = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
stashedCont?.let { cont ->
stashedCont = null
val data = result.data
if (data != null)
cont.resume(data)
else
cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER))
}
val continueAfterUserInteraction =
registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
stashedCont?.let { cont ->
stashedCont = null
val data = result.data
if (data != null) cont.resume(data)
else cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER))
}
}
}

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.SshPublicKeyResponse
class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : KeyProvider, Closeable {
class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) :
KeyProvider, Closeable {
companion object {
companion object {
suspend fun prepareAndUse(activity: ContinuationContainerActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) {
withContext(Dispatchers.Main) {
OpenKeychainKeyProvider(activity)
}.prepareAndUse(block)
}
suspend fun prepareAndUse(
activity: ContinuationContainerActivity,
block: (provider: OpenKeychainKeyProvider) -> Unit
) {
withContext(Dispatchers.Main) { OpenKeychainKeyProvider(activity) }.prepareAndUse(block)
}
}
private sealed class ApiResponse {
data class Success(val response: Response) : ApiResponse()
data class GeneralError(val exception: Exception) : ApiResponse()
data class NoSuchKey(val exception: Exception) : ApiResponse()
private sealed class ApiResponse {
data class Success(val response: Response) : ApiResponse()
data class GeneralError(val exception: Exception) : ApiResponse()
data class NoSuchKey(val exception: Exception) : ApiResponse()
}
private val context = activity.applicationContext
private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER)
private val preferences = context.sharedPrefs
private lateinit var sshServiceApi: SshAuthenticationApi
private var keyId
get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)
set(value) {
preferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) }
}
private var publicKey: PublicKey? = null
private var privateKey: OpenKeychainPrivateKey? = null
private val context = activity.applicationContext
private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER)
private val preferences = context.sharedPrefs
private lateinit var sshServiceApi: SshAuthenticationApi
private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) {
prepare()
use(block)
}
private var keyId
get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)
set(value) {
preferences.edit {
putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value)
private suspend fun prepare() {
sshServiceApi =
suspendCoroutine { cont ->
sshServiceConnection.connect(
object : SshAuthenticationConnection.OnBound {
override fun onBound(sshAgent: ISshAuthenticationService) {
d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" }
cont.resume(SshAuthenticationApi(context, sshAgent))
}
}
private var publicKey: PublicKey? = null
private var privateKey: OpenKeychainPrivateKey? = null
private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) {
prepare()
use(block)
}
private suspend fun prepare() {
sshServiceApi = suspendCoroutine { cont ->
sshServiceConnection.connect(object : SshAuthenticationConnection.OnBound {
override fun onBound(sshAgent: ISshAuthenticationService) {
d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" }
cont.resume(SshAuthenticationApi(context, sshAgent))
}
override fun onError() {
throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
}
})
}
if (keyId == null) {
selectKey()
}
check(keyId != null)
fetchPublicKey()
makePrivateKey()
}
private suspend fun fetchPublicKey(isRetry: Boolean = false) {
when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) {
is ApiResponse.Success -> {
val response = sshPublicKeyResponse.response as SshPublicKeyResponse
val sshPublicKey = response.sshPublicKey!!
publicKey = parseSshPublicKey(sshPublicKey)
?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
override fun onError() {
throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
}
is ApiResponse.NoSuchKey -> if (isRetry) {
throw sshPublicKeyResponse.exception
} else {
// Allow the user to reselect an authentication key and retry
selectKey()
fetchPublicKey(true)
}
)
}
if (keyId == null) {
selectKey()
}
check(keyId != null)
fetchPublicKey()
makePrivateKey()
}
private suspend fun fetchPublicKey(isRetry: Boolean = false) {
when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) {
is ApiResponse.Success -> {
val response = sshPublicKeyResponse.response as SshPublicKeyResponse
val sshPublicKey = response.sshPublicKey!!
publicKey =
parseSshPublicKey(sshPublicKey) ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
}
is ApiResponse.NoSuchKey ->
if (isRetry) {
throw sshPublicKeyResponse.exception
} else {
// Allow the user to reselect an authentication key and retry
selectKey()
fetchPublicKey(true)
}
is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception
}
}
private suspend fun selectKey() {
when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) {
is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId
is ApiResponse.GeneralError -> throw keySelectionResponse.exception
is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception
}
}
private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse {
d { "executeRequest($request) called" }
val result =
withContext(Dispatchers.Main) {
// If the request required user interaction, the data returned from the
// PendingIntent
// is used as the real request.
sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!!
}
return parseResult(request, result).also { d { "executeRequest($request): $it" } }
}
private suspend fun parseResult(request: Request, result: Intent): ApiResponse {
return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) {
SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
ApiResponse.Success(
when (request) {
is KeySelectionRequest -> KeySelectionResponse(result)
is SshPublicKeyRequest -> SshPublicKeyResponse(result)
is SigningRequest -> SigningResponse(result)
else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
}
)
}
SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
val resultOfUserInteraction: Intent =
withContext(Dispatchers.Main) {
suspendCoroutine { cont ->
activity.stashedCont = cont
activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build())
}
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() {
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 fun makePrivateKey() {
check(keyId != null && publicKey != null)
privateKey =
object : OpenKeychainPrivateKey {
override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) {
is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
is ApiResponse.GeneralError -> throw signingResponse.exception
is ApiResponse.NoSuchKey -> throw signingResponse.exception
}
override fun getAlgorithm() = publicKey!!.algorithm
override fun getParams() = (publicKey as? ECKey)?.params
}
}
override fun close() {
activity.lifecycleScope.launch {
withContext(Dispatchers.Main) { activity.continueAfterUserInteraction.unregister() }
}
sshServiceConnection.disconnect()
}
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" }
}
}
override fun getPrivate() = privateKey
private suspend fun parseResult(request: Request, result: Intent): ApiResponse {
return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) {
SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
ApiResponse.Success(when (request) {
is KeySelectionRequest -> KeySelectionResponse(result)
is SshPublicKeyRequest -> SshPublicKeyResponse(result)
is SigningRequest -> SigningResponse(result)
else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
})
}
SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) {
suspendCoroutine { cont ->
activity.stashedCont = cont
activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build())
}
}
executeApiRequest(request, resultOfUserInteraction)
}
else -> {
val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}")
when (error?.error) {
SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception)
else -> ApiResponse.GeneralError(exception)
}
}
}
}
override fun getPublic() = publicKey
private fun makePrivateKey() {
check(keyId != null && publicKey != null)
privateKey = object : OpenKeychainPrivateKey {
override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) {
is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
is ApiResponse.GeneralError -> throw signingResponse.exception
is ApiResponse.NoSuchKey -> throw signingResponse.exception
}
override fun getAlgorithm() = publicKey!!.algorithm
override fun getParams() = (publicKey as? ECKey)?.params
}
}
override fun close() {
activity.lifecycleScope.launch {
withContext(Dispatchers.Main) {
activity.continueAfterUserInteraction.unregister()
}
}
sshServiceConnection.disconnect()
}
override fun getPrivate() = privateKey
override fun getPublic() = publicKey
override fun getType(): KeyType = KeyType.fromKey(publicKey)
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.security.PrivateKey
import java.security.interfaces.ECKey
import java.security.interfaces.ECPrivateKey
import java.security.spec.ECParameterSpec
import kotlinx.coroutines.runBlocking
import net.schmizz.sshj.common.Buffer
import net.schmizz.sshj.common.Factory
@ -18,79 +16,83 @@ import org.openintents.ssh.authentication.SshAuthenticationApi
interface OpenKeychainPrivateKey : PrivateKey, ECKey {
suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray
suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray
override fun getFormat() = null
override fun getEncoded() = null
override fun getFormat() = null
override fun getEncoded() = null
}
class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) : Factory.Named<KeyAlgorithm> by factory {
class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) :
Factory.Named<KeyAlgorithm> by factory {
override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create())
override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create())
}
class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm {
private val hashAlgorithm = when (keyAlgorithm.keyAlgorithm) {
"rsa-sha2-512" -> SshAuthenticationApi.SHA512
"rsa-sha2-256" -> SshAuthenticationApi.SHA256
"ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1
// Other algorithms don't use this value, but it has to be valid.
else -> SshAuthenticationApi.SHA512
private val hashAlgorithm =
when (keyAlgorithm.keyAlgorithm) {
"rsa-sha2-512" -> SshAuthenticationApi.SHA512
"rsa-sha2-256" -> SshAuthenticationApi.SHA256
"ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1
// Other algorithms don't use this value, but it has to be valid.
else -> SshAuthenticationApi.SHA512
}
override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
}
class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : Signature by wrappedSignature {
class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) :
Signature by wrappedSignature {
private val data = ByteArrayOutputStream()
private val data = ByteArrayOutputStream()
private var bridgedPrivateKey: OpenKeychainPrivateKey? = null
private var bridgedPrivateKey: OpenKeychainPrivateKey? = null
override fun initSign(prvkey: PrivateKey?) {
if (prvkey is OpenKeychainPrivateKey) {
bridgedPrivateKey = prvkey
} else {
wrappedSignature.initSign(prvkey)
}
}
override fun 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)
}
override fun initSign(prvkey: PrivateKey?) {
if (prvkey is OpenKeychainPrivateKey) {
bridgedPrivateKey = prvkey
} 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) {
require(signature != null) { "OpenKeychain signature must not be null" }
val encodedSignature = Buffer.PlainBuffer(signature)
// We need to drop the algorithm name and extract the raw signature since SSHJ adds the name
// later.
encodedSignature.readString()
encodedSignature.readBytes().also {
bridgedPrivateKey = null
data.reset()
}
override fun encode(signature: ByteArray?): ByteArray? =
if (bridgedPrivateKey != null) {
require(signature != null) { "OpenKeychain signature must not be null" }
val encodedSignature = Buffer.PlainBuffer(signature)
// We need to drop the algorithm name and extract the raw signature since SSHJ adds the
// name
// later.
encodedSignature.readString()
encodedSignature.readBytes().also {
bridgedPrivateKey = null
data.reset()
}
} else {
wrappedSignature.encode(signature)
wrappedSignature.encode(signature)
}
}

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 val androidKeystore: KeyStore by lazy(LazyThreadSafetyMode.NONE) {
KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
}
private val KeyStore.sshPrivateKey
get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
private val KeyStore.sshPublicKey
get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
fun parseSshPublicKey(sshPublicKey: String): PublicKey? {
val sshKeyParts = sshPublicKey.split("""\s+""".toRegex())
if (sshKeyParts.size < 2)
return null
return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
val sshKeyParts = sshPublicKey.split("""\s+""".toRegex())
if (sshKeyParts.size < 2) return null
return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
}
fun toSshPublicKey(publicKey: PublicKey): String {
val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData
val keyType = KeyType.fromKey(publicKey)
return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}"
val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData
val keyType = KeyType.fromKey(publicKey)
return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}"
}
object SshKey {
val sshPublicKey
get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null
val canShowSshPublicKey
get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519)
val exists
get() = type != null
val mustAuthenticate: Boolean
get() {
return runCatching {
if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519))
return false
when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
is PrivateKey -> {
val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired
}
is SecretKey -> {
val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
(factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired
}
else -> throw IllegalStateException("SSH key does not exist in Keystore")
}
}.getOrElse { error ->
// It is fine to swallow the exception here since it will reappear when the key is
// used for SSH authentication and can then be shown in the UI.
d(error)
false
}
val sshPublicKey
get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null
val canShowSshPublicKey
get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519)
val exists
get() = type != null
val mustAuthenticate: Boolean
get() {
return runCatching {
if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) return false
when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
is PrivateKey -> {
val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired
}
is SecretKey -> {
val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
(factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired
}
else -> throw IllegalStateException("SSH key does not exist in Keystore")
}
private val context: Context
get() = Application.instance.applicationContext
private val privateKeyFile
get() = File(context.filesDir, ".ssh_key")
private val publicKeyFile
get() = File(context.filesDir, ".ssh_key.pub")
private var type: Type?
get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
set(value) = context.sharedPrefs.edit {
putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value)
}
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]
}
.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
}
}
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 val context: Context
get() = Application.instance.applicationContext
private val privateKeyFile
get() = File(context.filesDir, ".ssh_key")
private val publicKeyFile
get() = File(context.filesDir, ".ssh_key.pub")
private var type: Type?
get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
set(value) = context.sharedPrefs.edit { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) }
private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
else false
}
private enum class Type(val value: String) {
Imported("imported"),
KeystoreNative("keystore_native"),
KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
// Behaves like `Imported`, but allows to view the public key.
LegacyGenerated("legacy_generated"),
;
companion object {
fun fromValue(value: String?): Type? = values().associateBy { it.value }[value]
}
}
enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) {
Rsa(
KeyProperties.KEY_ALGORITHM_RSA,
{
setKeySize(3072)
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
}
),
Ecdsa(
KeyProperties.KEY_ALGORITHM_EC,
{
setKeySize(256)
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
setDigests(KeyProperties.DIGEST_SHA256)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setIsStrongBoxBacked(isStrongBoxSupported)
}
}
),
}
private fun delete() {
androidKeystore.deleteEntry(KEYSTORE_ALIAS)
// Remove Tink key set used by AndroidX's EncryptedFile.
context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { clear() }
if (privateKeyFile.isFile) {
privateKeyFile.delete()
}
if (publicKeyFile.isFile) {
publicKeyFile.delete()
}
context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
type = null
}
fun import(uri: Uri) {
// First check whether the content at uri is likely an SSH private key.
val fileSize =
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
// Cursor returns only a single row.
cursor.moveToFirst()
cursor.getInt(0)
}
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
// We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
if (fileSize > 100_000 || fileSize == 0)
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
val sshKeyInputStream =
context.contentResolver.openInputStream(uri)
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
val lines = sshKeyInputStream.bufferedReader().readLines()
// The file must have more than 2 lines, and the first and last line must have private key
// markers.
if (lines.size < 2 ||
!Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
!Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
)
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
// At this point, we are reasonably confident that we have actually been provided a private
// key and delete the old key.
delete()
// Canonicalize line endings to '\n'.
privateKeyFile.writeText(lines.joinToString("\n"))
type = Type.Imported
}
@Deprecated("To be used only in Migrations.kt")
fun useLegacyKey(isGenerated: Boolean) {
type = if (isGenerated) Type.LegacyGenerated else Type.Imported
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) =
withContext(Dispatchers.IO) {
MasterKey.Builder(context, KEYSTORE_ALIAS).run {
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
setRequestStrongBoxBacked(true)
setUserAuthenticationRequired(requireAuthentication, 15)
build()
}
}
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())
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) =
withContext(Dispatchers.IO) {
EncryptedFile.Builder(
context,
privateKeyFile,
getOrCreateWrappingMasterKey(requireAuthentication),
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
)
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()
.run {
setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME)
build()
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
EncryptedFile.Builder(context,
privateKeyFile,
getOrCreateWrappingMasterKey(requireAuthentication),
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).run {
setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME)
build()
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) =
withContext(Dispatchers.IO) {
delete()
val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication)
// Generate the ed25519 key pair and encrypt the private key.
val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair()
encryptedPrivateKeyFile.openFileOutput().use { os -> os.write((keyPair.private as EdDSAPrivateKey).seed) }
// Write public key in SSH format to .ssh_key.pub.
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
type = Type.KeystoreWrappedEd25519
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
delete()
fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) {
delete()
val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication)
// Generate the ed25519 key pair and encrypt the private key.
val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair()
encryptedPrivateKeyFile.openFileOutput().use { os ->
os.write((keyPair.private as EdDSAPrivateKey).seed)
// Generate Keystore-backed private key.
val parameterSpec =
KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run {
apply(algorithm.applyToSpec)
if (requireAuthentication) {
setUserAuthenticationRequired(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL)
} else {
@Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(30)
}
}
build()
}
val keyPair =
KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run {
initialize(parameterSpec)
generateKeyPair()
}
// Write public key in SSH format to .ssh_key.pub.
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
// Write public key in SSH format to .ssh_key.pub.
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) {
delete()
private object KeystoreNativeKeyProvider : KeyProvider {
// Generate Keystore-backed private key.
val parameterSpec = KeyGenParameterSpec.Builder(
KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN
).run {
apply(algorithm.applyToSpec)
if (requireAuthentication) {
setUserAuthenticationRequired(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL)
} else {
@Suppress("DEPRECATION")
setUserAuthenticationValidityDurationSeconds(30)
}
}
build()
}
val keyPair = KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run {
initialize(parameterSpec)
generateKeyPair()
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)
}
// Write public key in SSH format to .ssh_key.pub.
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
type = Type.KeystoreNative
}
fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = when (type) {
Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder)
Type.KeystoreNative -> KeystoreNativeKeyProvider
Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider
null -> null
}
private object KeystoreNativeKeyProvider : KeyProvider {
override fun getPublic(): PublicKey = runCatching {
androidKeystore.sshPublicKey!!
}.getOrElse { error ->
e(error)
throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error)
}
override fun getPrivate(): PrivateKey = runCatching {
androidKeystore.sshPrivateKey!!
}.getOrElse { error ->
e(error)
throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error)
}
override fun getType(): KeyType = KeyType.fromKey(public)
}
private object KeystoreWrappedEd25519KeyProvider : KeyProvider {
override fun getPublic(): PublicKey = runCatching {
parseSshPublicKey(sshPublicKey!!)!!
}.getOrElse { error ->
e(error)
throw IOException("Failed to get the public key for wrapped ed25519 key", error)
}
override fun getPrivate(): PrivateKey = runCatching {
// The current MasterKey API does not allow getting a reference to an existing one
// without specifying the KeySpec for a new one. However, the value for passed here
// for `requireAuthentication` is not used as the key already exists at this point.
val encryptedPrivateKeyFile = runBlocking {
getOrCreateWrappedPrivateKeyFile(false)
}
val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() }
EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC))
}.getOrElse { error ->
e(error)
throw IOException("Failed to unwrap wrapped ed25519 key", error)
}
override fun getType(): KeyType = KeyType.fromKey(public)
}
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.Marker
fun setUpBouncyCastleForSshj() {
// Replace the Android BC provider with the Java BouncyCastle provider since the former does
// not include all the required algorithms.
// Note: This may affect crypto operations in other parts of the application.
val bcIndex = Security.getProviders().indexOfFirst {
it.name == BouncyCastleProvider.PROVIDER_NAME
}
if (bcIndex == -1) {
// No Android BC found, install Java BC at lowest priority.
Security.addProvider(BouncyCastleProvider())
} else {
// Replace Android BC with Java BC, inserted at the same position.
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
// May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261
runCatching { Class.forName("sun.security.jca.Providers") }
Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
}
d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" }
// Prevent sshj from forwarding all cryptographic operations to BC.
SecurityUtils.setRegisterBouncyCastle(false)
SecurityUtils.setSecurityProvider(null)
// Replace the Android BC provider with the Java BouncyCastle provider since the former does
// not include all the required algorithms.
// Note: This may affect crypto operations in other parts of the application.
val bcIndex = Security.getProviders().indexOfFirst { it.name == BouncyCastleProvider.PROVIDER_NAME }
if (bcIndex == -1) {
// No Android BC found, install Java BC at lowest priority.
Security.addProvider(BouncyCastleProvider())
} else {
// Replace Android BC with Java BC, inserted at the same position.
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
// May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261
runCatching { Class.forName("sun.security.jca.Providers") }
Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
}
d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" }
// Prevent sshj from forwarding all cryptographic operations to BC.
SecurityUtils.setRegisterBouncyCastle(false)
SecurityUtils.setSecurityProvider(null)
}
private abstract class AbstractLogger(private val name: String) : Logger {
abstract fun t(message: String, t: Throwable? = null, vararg args: Any?)
abstract fun d(message: String, t: Throwable? = null, vararg args: Any?)
abstract fun i(message: String, t: Throwable? = null, vararg args: Any?)
abstract fun w(message: String, t: Throwable? = null, vararg args: Any?)
abstract fun e(message: String, t: Throwable? = null, vararg args: Any?)
abstract fun t(message: String, t: Throwable? = null, vararg args: Any?)
abstract fun d(message: String, t: Throwable? = null, vararg args: Any?)
abstract fun i(message: String, t: Throwable? = null, vararg args: Any?)
abstract fun w(message: String, t: Throwable? = null, vararg args: Any?)
abstract fun e(message: String, t: Throwable? = null, vararg args: Any?)
override fun getName() = name
override fun getName() = name
override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled
override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled
override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled
override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled
override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled
override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled
override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled
override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled
override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled
override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled
override fun trace(msg: String) = t(msg)
override fun trace(format: String, arg: Any?) = t(format, null, arg)
override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2)
override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments)
override fun trace(msg: String, t: Throwable?) = t(msg, t)
override fun trace(marker: Marker, msg: String) = trace(msg)
override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg)
override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
trace(format, arg1, arg2)
override fun trace(msg: String) = t(msg)
override fun trace(format: String, arg: Any?) = t(format, null, arg)
override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2)
override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments)
override fun trace(msg: String, t: Throwable?) = t(msg, t)
override fun trace(marker: Marker, msg: String) = trace(msg)
override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg)
override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = trace(format, arg1, arg2)
override fun trace(marker: Marker?, format: String, vararg arguments: Any?) =
trace(format, *arguments)
override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = 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(format: String, arg: Any?) = d(format, null, arg)
override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2)
override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments)
override fun debug(msg: String, t: Throwable?) = d(msg, t)
override fun debug(marker: Marker, msg: String) = debug(msg)
override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg)
override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
debug(format, arg1, arg2)
override fun debug(msg: String) = d(msg)
override fun debug(format: String, arg: Any?) = d(format, null, arg)
override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2)
override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments)
override fun debug(msg: String, t: Throwable?) = d(msg, t)
override fun debug(marker: Marker, msg: String) = debug(msg)
override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg)
override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = debug(format, arg1, arg2)
override fun debug(marker: Marker?, format: String, vararg arguments: Any?) =
debug(format, *arguments)
override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = 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(format: String, arg: Any?) = i(format, null, arg)
override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2)
override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments)
override fun info(msg: String, t: Throwable?) = i(msg, t)
override fun info(marker: Marker, msg: String) = info(msg)
override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg)
override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
info(format, arg1, arg2)
override fun info(msg: String) = i(msg)
override fun info(format: String, arg: Any?) = i(format, null, arg)
override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2)
override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments)
override fun info(msg: String, t: Throwable?) = i(msg, t)
override fun info(marker: Marker, msg: String) = info(msg)
override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg)
override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = info(format, arg1, arg2)
override fun info(marker: Marker?, format: String, vararg arguments: Any?) =
info(format, *arguments)
override fun info(marker: Marker?, format: String, vararg arguments: Any?) = 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(format: String, arg: Any?) = w(format, null, arg)
override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2)
override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments)
override fun warn(msg: String, t: Throwable?) = w(msg, t)
override fun warn(marker: Marker, msg: String) = warn(msg)
override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg)
override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
warn(format, arg1, arg2)
override fun warn(msg: String) = w(msg)
override fun warn(format: String, arg: Any?) = w(format, null, arg)
override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2)
override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments)
override fun warn(msg: String, t: Throwable?) = w(msg, t)
override fun warn(marker: Marker, msg: String) = warn(msg)
override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg)
override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = warn(format, arg1, arg2)
override fun warn(marker: Marker?, format: String, vararg arguments: Any?) =
warn(format, *arguments)
override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = 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(format: String, arg: Any?) = e(format, null, arg)
override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2)
override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments)
override fun error(msg: String, t: Throwable?) = e(msg, t)
override fun error(marker: Marker, msg: String) = error(msg)
override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg)
override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
error(format, arg1, arg2)
override fun error(msg: String) = e(msg)
override fun error(format: String, arg: Any?) = e(format, null, arg)
override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2)
override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments)
override fun error(msg: String, t: Throwable?) = e(msg, t)
override fun error(marker: Marker, msg: String) = error(msg)
override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg)
override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = error(format, arg1, arg2)
override fun error(marker: Marker?, format: String, vararg arguments: Any?) =
error(format, *arguments)
override fun error(marker: Marker?, format: String, vararg arguments: Any?) = 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 {
private class TimberLogger(name: String) : AbstractLogger(name) {
private class TimberLogger(name: String) : AbstractLogger(name) {
// We defer the log level checks to Timber.
override fun isTraceEnabled() = true
override fun isDebugEnabled() = true
override fun isInfoEnabled() = true
override fun isWarnEnabled() = true
override fun isErrorEnabled() = true
// We defer the log level checks to Timber.
override fun isTraceEnabled() = true
override fun isDebugEnabled() = true
override fun isInfoEnabled() = true
override fun isWarnEnabled() = true
override fun isErrorEnabled() = true
// Replace slf4j's "{}" format string style with standard Java's "%s".
// The supposedly redundant escape on the } is not redundant.
@Suppress("RegExpRedundantEscape")
private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
// Replace slf4j's "{}" format string style with standard Java's "%s".
// The supposedly redundant escape on the } is not redundant.
@Suppress("RegExpRedundantEscape") private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
override fun t(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).v(t, message.fix(), *args)
}
override fun d(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).d(t, message.fix(), *args)
}
override fun i(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).i(t, message.fix(), *args)
}
override fun w(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).w(t, message.fix(), *args)
}
override fun e(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).e(t, message.fix(), *args)
}
override fun t(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).v(t, message.fix(), *args)
}
override fun getLogger(name: String): Logger {
return TimberLogger(name)
override fun d(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).d(t, message.fix(), *args)
}
override fun getLogger(clazz: Class<*>): Logger {
return TimberLogger(clazz.name)
override fun i(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).i(t, message.fix(), *args)
}
override fun w(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).w(t, message.fix(), *args)
}
override fun e(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).e(t, message.fix(), *args)
}
}
override fun getLogger(name: String): Logger {
return TimberLogger(name)
}
override fun getLogger(clazz: Class<*>): Logger {
return TimberLogger(clazz.name)
}
}
class SshjConfig : ConfigImpl() {
init {
loggerFactory = TimberLoggerFactory
keepAliveProvider = KeepAliveProvider.HEARTBEAT
version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1"
init {
loggerFactory = TimberLoggerFactory
keepAliveProvider = KeepAliveProvider.HEARTBEAT
version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1"
initKeyExchangeFactories()
initKeyAlgorithms()
initRandomFactory()
initFileKeyProviderFactories()
initCipherFactories()
initCompressionFactories()
initMACFactories()
}
initKeyExchangeFactories()
initKeyAlgorithms()
initRandomFactory()
initFileKeyProviderFactories()
initCipherFactories()
initCompressionFactories()
initMACFactories()
}
private fun initKeyExchangeFactories() {
keyExchangeFactories = listOf(
Curve25519SHA256.Factory(),
FactoryLibSsh(),
ECDHNistP.Factory521(),
ECDHNistP.Factory384(),
ECDHNistP.Factory256(),
DHGexSHA256.Factory(),
// Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get
// rsa-sha2-* key types to work with some servers (e.g. GitHub).
ExtInfoClientFactory(),
)
}
private fun initKeyExchangeFactories() {
keyExchangeFactories =
listOf(
Curve25519SHA256.Factory(),
FactoryLibSsh(),
ECDHNistP.Factory521(),
ECDHNistP.Factory384(),
ECDHNistP.Factory256(),
DHGexSHA256.Factory(),
// Sends "ext-info-c" with the list of key exchange algorithms. This is needed to
// get
// rsa-sha2-* key types to work with some servers (e.g. GitHub).
ExtInfoClientFactory(),
)
}
private fun initKeyAlgorithms() {
keyAlgorithms = listOf(
KeyAlgorithms.SSHRSACertV01(),
KeyAlgorithms.EdDSA25519(),
KeyAlgorithms.ECDSASHANistp521(),
KeyAlgorithms.ECDSASHANistp384(),
KeyAlgorithms.ECDSASHANistp256(),
KeyAlgorithms.RSASHA512(),
KeyAlgorithms.RSASHA256(),
KeyAlgorithms.SSHRSA(),
).map {
OpenKeychainWrappedKeyAlgorithmFactory(it)
}
}
private fun initKeyAlgorithms() {
keyAlgorithms =
listOf(
KeyAlgorithms.SSHRSACertV01(),
KeyAlgorithms.EdDSA25519(),
KeyAlgorithms.ECDSASHANistp521(),
KeyAlgorithms.ECDSASHANistp384(),
KeyAlgorithms.ECDSASHANistp256(),
KeyAlgorithms.RSASHA512(),
KeyAlgorithms.RSASHA256(),
KeyAlgorithms.SSHRSA(),
)
.map { OpenKeychainWrappedKeyAlgorithmFactory(it) }
}
private fun initRandomFactory() {
randomFactory = SingletonRandomFactory(JCERandom.Factory())
}
private fun initRandomFactory() {
randomFactory = SingletonRandomFactory(JCERandom.Factory())
}
private fun initFileKeyProviderFactories() {
fileKeyProviderFactories = listOf(
OpenSSHKeyV1KeyFile.Factory(),
PKCS8KeyFile.Factory(),
PKCS5KeyFile.Factory(),
OpenSSHKeyFile.Factory(),
PuTTYKeyFile.Factory(),
)
}
private fun initFileKeyProviderFactories() {
fileKeyProviderFactories =
listOf(
OpenSSHKeyV1KeyFile.Factory(),
PKCS8KeyFile.Factory(),
PKCS5KeyFile.Factory(),
OpenSSHKeyFile.Factory(),
PuTTYKeyFile.Factory(),
)
}
private fun initCipherFactories() {
cipherFactories =
listOf(
GcmCiphers.AES128GCM(),
GcmCiphers.AES256GCM(),
BlockCiphers.AES256CTR(),
BlockCiphers.AES192CTR(),
BlockCiphers.AES128CTR(),
)
}
private fun initCipherFactories() {
cipherFactories = listOf(
GcmCiphers.AES128GCM(),
GcmCiphers.AES256GCM(),
BlockCiphers.AES256CTR(),
BlockCiphers.AES192CTR(),
BlockCiphers.AES128CTR(),
)
}
private fun initMACFactories() {
macFactories =
listOf(
Macs.HMACSHA2512Etm(),
Macs.HMACSHA2256Etm(),
Macs.HMACSHA2512(),
Macs.HMACSHA2256(),
)
}
private fun initMACFactories() {
macFactories = listOf(
Macs.HMACSHA2512Etm(),
Macs.HMACSHA2256Etm(),
Macs.HMACSHA2512(),
Macs.HMACSHA2256(),
)
}
private fun initCompressionFactories() {
compressionFactories = listOf(
NoneCompression.Factory(),
)
}
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
sealed class SshAuthMethod(val activity: ContinuationContainerActivity) {
class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
}
abstract class InteractivePasswordFinder : PasswordFinder {
private var isRetry = false
private var isRetry = false
abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean)
abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean)
final override fun reqPassword(resource: Resource<*>?): CharArray {
val password = runBlocking(Dispatchers.Main) {
suspendCoroutine<String?> { cont ->
askForPassword(cont, isRetry)
}
}
isRetry = true
return password?.toCharArray()
?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
}
final override fun reqPassword(resource: Resource<*>?): CharArray {
val password = runBlocking(Dispatchers.Main) { suspendCoroutine<String?> { cont -> askForPassword(cont, isRetry) } }
isRetry = true
return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
}
final override fun shouldRetry(resource: Resource<*>?) = true
final override fun shouldRetry(resource: Resource<*>?) = true
}
class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : SshSessionFactory() {
private var currentSession: SshjSession? = null
private var currentSession: SshjSession? = null
override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession {
return currentSession
?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
d { "New SSH connection created" }
currentSession = it
}
}
override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession {
return currentSession
?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
d { "New SSH connection created" }
currentSession = it
}
}
fun close() {
currentSession?.close()
}
fun close() {
currentSession?.close()
}
}
private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
if (!hostKeyFile.exists()) {
return HostKeyVerifier { _, _, key ->
val digest = runCatching {
SecurityUtils.getMessageDigest("SHA-256")
}.getOrElse { e ->
throw SSHRuntimeException(e)
}
digest.update(PlainBuffer().putPublicKey(key).compactData)
val digestData = digest.digest()
val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}"
d { "Trusting host key on first use: $hostKeyEntry" }
hostKeyFile.writeText(hostKeyEntry)
true
}
} else {
val hostKeyEntry = hostKeyFile.readText()
d { "Pinned host key: $hostKeyEntry" }
return FingerprintVerifier.getInstance(hostKeyEntry)
if (!hostKeyFile.exists()) {
return HostKeyVerifier { _, _, key ->
val digest =
runCatching { SecurityUtils.getMessageDigest("SHA-256") }.getOrElse { e -> throw SSHRuntimeException(e) }
digest.update(PlainBuffer().putPublicKey(key).compactData)
val digestData = digest.digest()
val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}"
d { "Trusting host key on first use: $hostKeyEntry" }
hostKeyFile.writeText(hostKeyEntry)
true
}
} else {
val hostKeyEntry = hostKeyFile.readText()
d { "Pinned host key: $hostKeyEntry" }
return FingerprintVerifier.getInstance(hostKeyEntry)
}
}
private class SshjSession(uri: URIish, private val username: String, private val authMethod: SshAuthMethod, private val hostKeyFile: File) : RemoteSession {
private class SshjSession(
uri: URIish,
private val username: String,
private val authMethod: SshAuthMethod,
private val hostKeyFile: File
) : RemoteSession {
private lateinit var ssh: SSHClient
private var currentCommand: Session? = null
private lateinit var ssh: SSHClient
private var currentCommand: Session? = null
private val uri = if (uri.host.contains('@')) {
// URIish's String constructor cannot handle '@' in the user part of the URI and the URL
// constructor can't be used since Java's URL does not recognize the ssh scheme. We thus
// need to patch everything up ourselves.
d { "Before fixup: user=${uri.user}, host=${uri.host}" }
val userPlusHost = "${uri.user}@${uri.host}"
val realUser = userPlusHost.substringBeforeLast('@')
val realHost = userPlusHost.substringAfterLast('@')
uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } }
private val uri =
if (uri.host.contains('@')) {
// URIish's String constructor cannot handle '@' in the user part of the URI and the URL
// constructor can't be used since Java's URL does not recognize the ssh scheme. We thus
// need to patch everything up ourselves.
d { "Before fixup: user=${uri.user}, host=${uri.host}" }
val userPlusHost = "${uri.user}@${uri.host}"
val realUser = userPlusHost.substringBeforeLast('@')
val realHost = userPlusHost.substringAfterLast('@')
uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } }
} else {
uri
uri
}
fun connect(): SshjSession {
ssh = SSHClient(SshjConfig())
ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile))
ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
if (!ssh.isConnected)
throw IOException()
val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password))
when (authMethod) {
is SshAuthMethod.Password -> {
ssh.auth(username, passwordAuth)
}
is SshAuthMethod.SshKey -> {
val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
ssh.auth(username, pubkeyAuth, passwordAuth)
}
is SshAuthMethod.OpenKeychain -> {
runBlocking {
OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider ->
val openKeychainAuth = AuthPublickey(provider)
ssh.auth(username, openKeychainAuth, passwordAuth)
}
}
}
fun connect(): SshjSession {
ssh = SSHClient(SshjConfig())
ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile))
ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
if (!ssh.isConnected) throw IOException()
val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password))
when (authMethod) {
is SshAuthMethod.Password -> {
ssh.auth(username, passwordAuth)
}
is SshAuthMethod.SshKey -> {
val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
ssh.auth(username, pubkeyAuth, passwordAuth)
}
is SshAuthMethod.OpenKeychain -> {
runBlocking {
OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider ->
val openKeychainAuth = AuthPublickey(provider)
ssh.auth(username, openKeychainAuth, passwordAuth)
}
}
return this
}
}
return this
}
override fun exec(commandName: String?, timeout: Int): Process {
if (currentCommand != null) {
w { "Killing old command" }
disconnect()
}
val session = ssh.startSession()
currentCommand = session
return SshjProcess(session.exec(commandName), timeout.toLong())
override fun exec(commandName: String?, timeout: Int): Process {
if (currentCommand != null) {
w { "Killing old command" }
disconnect()
}
val session = ssh.startSession()
currentCommand = session
return SshjProcess(session.exec(commandName), timeout.toLong())
}
/**
* Kills the current command if one is running and returns the session into a state where `exec`
* can be called.
*
* Note that this does *not* disconnect the session. Unfortunately, the function has to be
* called `disconnect` to override the corresponding abstract function in `RemoteSession`.
*/
override fun disconnect() {
currentCommand?.close()
currentCommand = null
}
/**
* Kills the current command if one is running and returns the session into a state where `exec`
* can be called.
*
* Note that this does *not* disconnect the session. Unfortunately, the function has to be called
* `disconnect` to override the corresponding abstract function in `RemoteSession`.
*/
override fun disconnect() {
currentCommand?.close()
currentCommand = null
}
fun close() {
disconnect()
ssh.close()
}
fun close() {
disconnect()
ssh.close()
}
}
private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() {
override fun waitFor(): Int {
command.join(timeout, TimeUnit.SECONDS)
command.close()
return exitValue()
}
override fun waitFor(): Int {
command.join(timeout, TimeUnit.SECONDS)
command.close()
return exitValue()
}
override fun destroy() = command.close()
override fun destroy() = command.close()
override fun getOutputStream(): OutputStream = command.outputStream
override fun getOutputStream(): OutputStream = command.outputStream
override fun getErrorStream(): InputStream = command.errorStream
override fun getErrorStream(): InputStream = command.errorStream
override fun exitValue(): Int = command.exitStatus
override fun exitValue(): Int = command.exitStatus
override fun getInputStream(): InputStream = command.inputStream
override fun getInputStream(): InputStream = command.inputStream
}

View file

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

View file

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

View file

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

View file

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

View file

@ -9,161 +9,161 @@ import java.util.Locale
object RandomPhonemesGenerator {
private const val CONSONANT = 0x0001
private const val VOWEL = 0x0002
private const val DIPHTHONG = 0x0004
private const val NOT_FIRST = 0x0008
private const val CONSONANT = 0x0001
private const val VOWEL = 0x0002
private const val DIPHTHONG = 0x0004
private const val NOT_FIRST = 0x0008
private val elements = arrayOf(
Element("a", VOWEL),
Element("ae", VOWEL or DIPHTHONG),
Element("ah", VOWEL or DIPHTHONG),
Element("ai", VOWEL or DIPHTHONG),
Element("b", CONSONANT),
Element("c", CONSONANT),
Element("ch", CONSONANT or DIPHTHONG),
Element("d", CONSONANT),
Element("e", VOWEL),
Element("ee", VOWEL or DIPHTHONG),
Element("ei", VOWEL or DIPHTHONG),
Element("f", CONSONANT),
Element("g", CONSONANT),
Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST),
Element("h", CONSONANT),
Element("i", VOWEL),
Element("ie", VOWEL or DIPHTHONG),
Element("j", CONSONANT),
Element("k", CONSONANT),
Element("l", CONSONANT),
Element("m", CONSONANT),
Element("n", CONSONANT),
Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST),
Element("o", VOWEL),
Element("oh", VOWEL or DIPHTHONG),
Element("oo", VOWEL or DIPHTHONG),
Element("p", CONSONANT),
Element("ph", CONSONANT or DIPHTHONG),
Element("qu", CONSONANT or DIPHTHONG),
Element("r", CONSONANT),
Element("s", CONSONANT),
Element("sh", CONSONANT or DIPHTHONG),
Element("t", CONSONANT),
Element("th", CONSONANT or DIPHTHONG),
Element("u", VOWEL),
Element("v", CONSONANT),
Element("w", CONSONANT),
Element("x", CONSONANT),
Element("y", CONSONANT),
Element("z", CONSONANT)
private val elements =
arrayOf(
Element("a", VOWEL),
Element("ae", VOWEL or DIPHTHONG),
Element("ah", VOWEL or DIPHTHONG),
Element("ai", VOWEL or DIPHTHONG),
Element("b", CONSONANT),
Element("c", CONSONANT),
Element("ch", CONSONANT or DIPHTHONG),
Element("d", CONSONANT),
Element("e", VOWEL),
Element("ee", VOWEL or DIPHTHONG),
Element("ei", VOWEL or DIPHTHONG),
Element("f", CONSONANT),
Element("g", CONSONANT),
Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST),
Element("h", CONSONANT),
Element("i", VOWEL),
Element("ie", VOWEL or DIPHTHONG),
Element("j", CONSONANT),
Element("k", CONSONANT),
Element("l", CONSONANT),
Element("m", CONSONANT),
Element("n", CONSONANT),
Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST),
Element("o", VOWEL),
Element("oh", VOWEL or DIPHTHONG),
Element("oo", VOWEL or DIPHTHONG),
Element("p", CONSONANT),
Element("ph", CONSONANT or DIPHTHONG),
Element("qu", CONSONANT or DIPHTHONG),
Element("r", CONSONANT),
Element("s", CONSONANT),
Element("sh", CONSONANT or DIPHTHONG),
Element("t", CONSONANT),
Element("th", CONSONANT or DIPHTHONG),
Element("u", VOWEL),
Element("v", CONSONANT),
Element("w", CONSONANT),
Element("x", CONSONANT),
Element("y", CONSONANT),
Element("z", CONSONANT)
)
private class Element(str: String, val flags: Int) {
private class Element(str: String, val flags: Int) {
val upperCase = str.toUpperCase(Locale.ROOT)
val lowerCase = str.toLowerCase(Locale.ROOT)
val length = str.length
val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR }
}
val upperCase = str.toUpperCase(Locale.ROOT)
val lowerCase = str.toLowerCase(Locale.ROOT)
val length = str.length
val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR }
}
/**
* Generates a random human-readable password of length [targetLength], taking the following
* flags in [pwFlags] into account, or fails to do so and returns null:
*
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not
* set, the password will not contain any digits.
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase
* letter; if not set, the password will not contain any uppercase letters.
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase
* letter; if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any
* lowercase characters; if both are not set, an exception is thrown.
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
* set, the password will not contain any symbols.
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
* characters.
*/
fun generate(targetLength: Int, pwFlags: Int): String? {
require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS)
/**
* Generates a random human-readable password of length [targetLength], taking the following flags
* in [pwFlags] into account, or fails to do so and returns null:
*
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set,
* the password will not contain any digits.
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter;
* if not set, the password will not contain any uppercase letters.
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter;
* if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any lowercase
* characters; if both are not set, an exception is thrown.
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
* set, the password will not contain any symbols.
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
* characters.
*/
fun generate(targetLength: Int, pwFlags: Int): String? {
require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS)
var password = ""
var password = ""
var isStartOfPart = true
var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
var previousFlags = 0
var isStartOfPart = true
var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
var previousFlags = 0
while (password.length < targetLength) {
// First part: Add a single letter or pronounceable pair of letters in varying case.
while (password.length < targetLength) {
// 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.
if (!candidate.flags.hasFlag(nextBasicType) ||
(isStartOfPart && candidate.flags hasFlag NOT_FIRST) ||
// Don't let a diphthong that starts with a vowel follow a vowel.
(previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) ||
// Don't add multi-character candidates if we would go over the targetLength.
(password.length + candidate.length > targetLength) ||
(pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)) {
continue
}
// Reroll if the candidate does not fulfill the current requirements.
if (!candidate.flags.hasFlag(nextBasicType) ||
(isStartOfPart && candidate.flags hasFlag NOT_FIRST) ||
// Don't let a diphthong that starts with a vowel follow a vowel.
(previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) ||
// Don't add multi-character candidates if we would go over the targetLength.
(password.length + candidate.length > targetLength) ||
(pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)
) {
continue
}
// At this point the candidate could be appended to the password, but we still have
// to determine the case. If no upper case characters are required, we don't add
// any.
val useUpperIfBothCasesAllowed =
(isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20)
password += if (pwFlags hasFlag PasswordGenerator.UPPERS &&
(!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)) {
candidate.upperCase
} else {
candidate.lowerCase
}
// We ensured above that we will not go above the target length.
check(password.length <= targetLength)
if (password.length == targetLength)
break
// Second part: Add digits and symbols with a certain probability (if requested) if
// they would not directly follow the first character in a pronounceable part.
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS &&
secureRandomBiasedBoolean(30)) {
var randomDigit: Char
do {
randomDigit = secureRandomNumber(10).toString(10).first()
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
randomDigit in PasswordGenerator.AMBIGUOUS_STR)
password += randomDigit
// Begin a new pronounceable part after every digit.
isStartOfPart = true
nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
previousFlags = 0
continue
}
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS &&
secureRandomBiasedBoolean(20)) {
var randomSymbol: Char
do {
randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter()
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
randomSymbol in PasswordGenerator.AMBIGUOUS_STR)
password += randomSymbol
// Continue the password generation as if nothing was added.
}
// Third part: Determine the basic type of the next character depending on the letter
// we just added.
nextBasicType = when {
candidate.flags.hasFlag(CONSONANT) -> VOWEL
previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) ||
secureRandomBiasedBoolean(60) -> CONSONANT
else -> VOWEL
}
previousFlags = candidate.flags
isStartOfPart = false
// At this point the candidate could be appended to the password, but we still have
// to determine the case. If no upper case characters are required, we don't add
// any.
val useUpperIfBothCasesAllowed =
(isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20)
password +=
if (pwFlags hasFlag PasswordGenerator.UPPERS &&
(!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)
) {
candidate.upperCase
} else {
candidate.lowerCase
}
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
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) {
private var numSymbols = 0
private var isAppendSymbolsSeparator = false
private var context = ctx
private var numWords = 3
private var maxWordLength = 9
private var minWordLength = 5
private var separator = "."
private var capsType = CapsType.Sentence
private var prependDigits = 0
private var numDigits = 0
private var isPrependWithSeparator = false
private var isAppendNumberSeparator = false
private var numSymbols = 0
private var isAppendSymbolsSeparator = false
private var context = ctx
private var numWords = 3
private var maxWordLength = 9
private var minWordLength = 5
private var separator = "."
private var capsType = CapsType.Sentence
private var prependDigits = 0
private var numDigits = 0
private var isPrependWithSeparator = false
private var isAppendNumberSeparator = false
fun setNumberOfWords(amount: Int) = apply {
numWords = amount
fun setNumberOfWords(amount: Int) = apply { numWords = amount }
fun setMinimumWordLength(min: Int) = apply { minWordLength = min }
fun setMaximumWordLength(max: Int) = apply { maxWordLength = max }
fun setSeparator(separator: String) = apply { this.separator = separator }
fun setCapitalization(capitalizationScheme: CapsType) = apply { capsType = capitalizationScheme }
@JvmOverloads
fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply {
prependDigits = numDigits
isPrependWithSeparator = addSeparator
}
@JvmOverloads
fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply {
this.numDigits = numDigits
isAppendNumberSeparator = addSeparator
}
@JvmOverloads
fun appendSymbols(numSymbols: Int, addSeparator: Boolean = false) = apply {
this.numSymbols = numSymbols
isAppendSymbolsSeparator = addSeparator
}
private fun generateRandomNumberSequence(totalNumbers: Int): String {
val numbers = StringBuilder(totalNumbers)
for (i in 0 until totalNumbers) {
numbers.append(secureRandomNumber(10))
}
return numbers.toString()
}
fun setMinimumWordLength(min: Int) = apply {
minWordLength = min
private fun generateRandomSymbolSequence(numSymbols: Int): String {
val numbers = StringBuilder(numSymbols)
for (i in 0 until numSymbols) {
numbers.append(SYMBOLS.secureRandomCharacter())
}
return numbers.toString()
}
fun setMaximumWordLength(max: Int) = apply {
maxWordLength = max
@OptIn(ExperimentalStdlibApi::class)
fun create(): Result<String, Throwable> {
val wordBank = mutableListOf<String>()
val password = StringBuilder()
if (prependDigits != 0) {
password.append(generateRandomNumberSequence(prependDigits))
if (isPrependWithSeparator) {
password.append(separator)
}
}
return runCatching {
val dictionary = XkpwdDictionary(context)
val words = dictionary.words
for (wordLength in minWordLength..maxWordLength) {
wordBank.addAll(words[wordLength] ?: emptyList())
}
fun setSeparator(separator: String) = apply {
this.separator = separator
}
if (wordBank.size == 0) {
throw PasswordGeneratorException(
context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength)
)
}
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))
for (i in 0 until numWords) {
val candidate = wordBank.secureRandomElement()
val s =
when (capsType) {
CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault())
CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate
CapsType.TitleCase -> candidate.capitalize(Locale.getDefault())
CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault())
CapsType.As_iS -> candidate
}
password.append(s)
if (i + 1 < numWords) {
password.append(separator)
}
return numbers.toString()
}
private fun generateRandomSymbolSequence(numSymbols: Int): String {
val numbers = StringBuilder(numSymbols)
for (i in 0 until numSymbols) {
numbers.append(SYMBOLS.secureRandomCharacter())
}
if (numDigits != 0) {
if (isAppendNumberSeparator) {
password.append(separator)
}
return numbers.toString()
}
@OptIn(ExperimentalStdlibApi::class)
fun create(): Result<String, Throwable> {
val wordBank = mutableListOf<String>()
val password = StringBuilder()
if (prependDigits != 0) {
password.append(generateRandomNumberSequence(prependDigits))
if (isPrependWithSeparator) {
password.append(separator)
}
}
return runCatching {
val dictionary = XkpwdDictionary(context)
val words = dictionary.words
for (wordLength in minWordLength..maxWordLength) {
wordBank.addAll(words[wordLength] ?: emptyList())
}
if (wordBank.size == 0) {
throw PasswordGeneratorException(context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength))
}
for (i in 0 until numWords) {
val candidate = wordBank.secureRandomElement()
val s = when (capsType) {
CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault())
CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate
CapsType.TitleCase -> candidate.capitalize(Locale.getDefault())
CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault())
CapsType.As_iS -> candidate
}
password.append(s)
if (i + 1 < numWords) {
password.append(separator)
}
}
if (numDigits != 0) {
if (isAppendNumberSeparator) {
password.append(separator)
}
password.append(generateRandomNumberSequence(numDigits))
}
if (numSymbols != 0) {
if (isAppendSymbolsSeparator) {
password.append(separator)
}
password.append(generateRandomSymbolSequence(numSymbols))
}
password.toString()
password.append(generateRandomNumberSequence(numDigits))
}
if (numSymbols != 0) {
if (isAppendSymbolsSeparator) {
password.append(separator)
}
password.append(generateRandomSymbolSequence(numSymbols))
}
password.toString()
}
}
companion object {
companion object {
private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#"
}
private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#"
}
}

View file

@ -13,28 +13,28 @@ import java.io.File
class XkpwdDictionary(context: Context) {
val words: Map<Int, List<String>>
val words: Map<Int, List<String>>
init {
val prefs = context.sharedPrefs
val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: ""
val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE)
init {
val prefs = context.sharedPrefs
val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: ""
val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE)
val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) &&
uri.isNotEmpty() && customDictFile.canRead()) {
customDictFile.readLines()
} else {
context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines()
}
val lines =
if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) &&
uri.isNotEmpty() &&
customDictFile.canRead()
) {
customDictFile.readLines()
} else {
context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines()
}
words = lines.asSequence()
.map { it.trim() }
.filter { it.isNotEmpty() && !it.contains(' ') }
.groupBy { it.length }
}
words = lines.asSequence().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() {
private val scope = CoroutineScope(Job() + Dispatchers.Main)
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) {
when (intent.action) {
ACTION_CLEAR -> {
clearClipboard()
stopForeground(true)
stopSelf()
return super.onStartCommand(intent, flags, startId)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent != null) {
when (intent.action) {
ACTION_CLEAR -> {
clearClipboard()
stopForeground(true)
stopSelf()
return super.onStartCommand(intent, flags, startId)
}
ACTION_START -> {
val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45)
ACTION_START -> {
val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45)
if (time == 0) {
stopSelf()
}
if (time == 0) {
stopSelf()
}
createNotification(time)
scope.launch {
withContext(Dispatchers.IO) {
startTimer(time)
}
withContext(Dispatchers.Main) {
clearClipboard()
stopForeground(true)
stopSelf()
}
}
return START_NOT_STICKY
}
createNotification(time)
scope.launch {
withContext(Dispatchers.IO) { startTimer(time) }
withContext(Dispatchers.Main) {
clearClipboard()
stopForeground(true)
stopSelf()
}
}
return START_NOT_STICKY
}
return super.onStartCommand(intent, flags, startId)
}
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? {
return null
}
private fun clearClipboard() {
val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false)
val clipboard = clipboard
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
if (clipboard != null) {
scope.launch {
d { "Clearing the clipboard" }
val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
clipboard.setPrimaryClip(clip)
if (deepClear) {
withContext(Dispatchers.IO) {
repeat(CLIPBOARD_CLEAR_COUNT) {
val count = (it * 500).toString()
clipboard.setPrimaryClip(ClipData.newPlainText(count, count))
}
}
}
private fun clearClipboard() {
val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false)
val clipboard = clipboard
if (clipboard != null) {
scope.launch {
d { "Clearing the clipboard" }
val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
clipboard.setPrimaryClip(clip)
if (deepClear) {
withContext(Dispatchers.IO) {
repeat(CLIPBOARD_CLEAR_COUNT) {
val count = (it * 500).toString()
clipboard.setPrimaryClip(ClipData.newPlainText(count, count))
}
} else {
d { "Cannot get clipboard manager service" }
}
}
}
} else {
d { "Cannot get clipboard manager service" }
}
}
private suspend fun startTimer(showTime: Int) {
var current = 0
while (scope.isActive && current < showTime) {
// Block for 1s or until cancel is signalled
current++
delay(1000)
}
private suspend fun startTimer(showTime: Int) {
var current = 0
while (scope.isActive && current < showTime) {
// Block for 1s or until cancel is signalled
current++
delay(1000)
}
}
private fun createNotification(clearTime: Int) {
val clearTimeMs = clearTime * 1000L
val clearIntent = Intent(this, ClipboardService::class.java).apply {
action = ACTION_CLEAR
}
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
} else {
PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
}
val notification = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
createNotificationApi23(pendingIntent)
} else {
createNotificationApi24(pendingIntent, clearTimeMs)
}
private fun createNotification(clearTime: Int) {
val clearTimeMs = clearTime * 1000L
val clearIntent = Intent(this, ClipboardService::class.java).apply { action = ACTION_CLEAR }
val pendingIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
} else {
PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
}
val notification =
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
createNotificationApi23(pendingIntent)
} else {
createNotificationApi24(pendingIntent, clearTimeMs)
}
createNotificationChannel()
startForeground(1, notification)
createNotificationChannel()
startForeground(1, notification)
}
private fun createNotificationApi23(pendingIntent: PendingIntent): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.tap_clear_clipboard))
.setSmallIcon(R.drawable.ic_action_secure_24dp)
.setContentIntent(pendingIntent)
.setUsesChronometer(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
@RequiresApi(Build.VERSION_CODES.N)
private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.tap_clear_clipboard))
.setSmallIcon(R.drawable.ic_action_secure_24dp)
.setContentIntent(pendingIntent)
.setUsesChronometer(true)
.setChronometerCountDown(true)
.setShowWhen(true)
.setWhen(System.currentTimeMillis() + clearTimeMs)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel =
NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW)
val manager = getSystemService<NotificationManager>()
if (manager != null) {
manager.createNotificationChannel(serviceChannel)
} else {
d { "Failed to create notification channel" }
}
}
}
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()
}
companion object {
@RequiresApi(Build.VERSION_CODES.N)
private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.tap_clear_clipboard))
.setSmallIcon(R.drawable.ic_action_secure_24dp)
.setContentIntent(pendingIntent)
.setUsesChronometer(true)
.setChronometerCountDown(true)
.setShowWhen(true)
.setWhen(System.currentTimeMillis() + clearTimeMs)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel(
CHANNEL_ID,
getString(R.string.app_name),
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService<NotificationManager>()
if (manager != null) {
manager.createNotificationChannel(serviceChannel)
} else {
d { "Failed to create notification channel" }
}
}
}
companion object {
const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER"
const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME"
private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
private const val CHANNEL_ID = "NotificationService"
// Newest Samsung phones now feature a history of up to 30 items. To err on the side of caution,
// push 35 fake ones.
private const val CLIPBOARD_CLEAR_COUNT = 35
}
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)
class OreoAutofillService : AutofillService() {
companion object {
companion object {
// TODO: Provide a user-configurable denylist
private val DENYLISTED_PACKAGES = listOf(
BuildConfig.APPLICATION_ID,
"android",
"com.android.settings",
"com.android.settings.intelligence",
"com.android.systemui",
"com.oneplus.applocker",
"org.sufficientlysecure.keychain",
)
// TODO: Provide a user-configurable denylist
private val DENYLISTED_PACKAGES =
listOf(
BuildConfig.APPLICATION_ID,
"android",
"com.android.settings",
"com.android.settings.intelligence",
"com.android.systemui",
"com.oneplus.applocker",
"org.sufficientlysecure.keychain",
)
private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L
}
private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L
}
override fun onCreate() {
super.onCreate()
cachePublicSuffixList(applicationContext)
}
override fun onCreate() {
super.onCreate()
cachePublicSuffixList(applicationContext)
}
override fun onFillRequest(
request: FillRequest,
cancellationSignal: CancellationSignal,
callback: FillCallback
) {
val structure = request.fillContexts.lastOrNull()?.structure ?: run {
callback.onSuccess(null)
return
}
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
override fun onFillRequest(request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback) {
val structure =
request.fillContexts.lastOrNull()?.structure
?: run {
callback.onSuccess(null)
return
}
if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
if (Build.VERSION.SDK_INT >= 28) {
callback.onSuccess(
AutofillSaveActivity.makeSaveIntentSender(
this,
credentials = Credentials(username, password, null),
formOrigin = formOrigin
)
FillResponse.Builder().run {
disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
build()
}
)
} else {
callback.onSuccess(null)
}
return
}
val formToFill =
FillableForm.parseAssistStructure(
this,
structure,
isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST,
getCustomSuffixes(),
)
?: run {
d { "Form cannot be filled" }
callback.onSuccess(null)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback)
} else {
AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
}
}
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
// SaveCallback's behavior and feature set differs based on both target and device SDK, so
// we replace it with a wrapper that works the same in all situations.
@Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback)
val structure =
request.fillContexts.lastOrNull()?.structure
?: run {
callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported))
return
}
val clientState =
request.clientState
?: run {
e { "Received save request without client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val scenario =
AutofillScenario.fromClientState(clientState)?.recoverNodes(structure)
?: run {
e { "Failed to recover client state or nodes from client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val formOrigin =
FormOrigin.fromBundle(clientState)
?: run {
e { "Failed to recover form origin from client state" }
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
return
}
val username = scenario.usernameValue
val password =
scenario.passwordValue
?: run {
callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match))
return
}
callback.onSuccess(
AutofillSaveActivity.makeSaveIntentSender(
this,
credentials = Credentials(username, password, null),
formOrigin = formOrigin
)
)
}
}
fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
fun Context.getCustomSuffixes(): Sequence<String> {
return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
?.splitToSequence('\n')
?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
?: emptySequence()
return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)?.splitToSequence('\n')?.filter {
it.isNotBlank() && it.first() != '.' && it.last() != '.'
}
?: emptySequence()
}

View file

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

View file

@ -17,191 +17,168 @@ import java.io.File
import org.eclipse.jgit.transport.URIish
enum class Protocol(val pref: String) {
Ssh("ssh://"),
Https("https://"),
;
Ssh("ssh://"),
Https("https://"),
;
companion object {
companion object {
private val map = values().associateBy(Protocol::pref)
fun fromString(type: String?): Protocol {
return map[type ?: return Ssh]
?: throw IllegalArgumentException("$type is not a valid Protocol")
}
private val map = values().associateBy(Protocol::pref)
fun fromString(type: String?): Protocol {
return map[type ?: return Ssh] ?: throw IllegalArgumentException("$type is not a valid Protocol")
}
}
}
enum class AuthMode(val pref: String) {
SshKey("ssh-key"),
Password("username/password"),
OpenKeychain("OpenKeychain"),
None("None"),
;
SshKey("ssh-key"),
Password("username/password"),
OpenKeychain("OpenKeychain"),
None("None"),
;
companion object {
companion object {
private val map = values().associateBy(AuthMode::pref)
fun fromString(type: String?): AuthMode {
return map[type ?: return SshKey]
?: throw IllegalArgumentException("$type is not a valid AuthMode")
}
private val map = values().associateBy(AuthMode::pref)
fun fromString(type: String?): AuthMode {
return map[type ?: return SshKey] ?: throw IllegalArgumentException("$type is not a valid AuthMode")
}
}
}
object GitSettings {
private const val DEFAULT_BRANCH = "master"
private const val DEFAULT_BRANCH = "master"
private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs }
private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedGitPrefs() }
private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() }
private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" }
private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs }
private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) {
Application.instance.getEncryptedGitPrefs()
}
private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() }
private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" }
var authMode
get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
private set(value) {
settings.edit {
putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref)
}
}
var url
get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL)
private set(value) {
require(value != null)
if (value == url)
return
settings.edit {
putString(PreferenceKeys.GIT_REMOTE_URL, value)
}
if (PasswordRepository.isInitialized)
PasswordRepository.addRemote("origin", value, true)
// When the server changes, remote password, multiplexing support and host key file
// should be deleted/reset.
useMultiplexing = true
encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
clearSavedHostKey()
}
var authorName
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
set(value) {
settings.edit {
putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value)
}
}
var authorEmail
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: ""
set(value) {
settings.edit {
putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value)
}
}
var branch
get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH
private set(value) {
settings.edit {
putString(PreferenceKeys.GIT_BRANCH_NAME, value)
}
}
var useMultiplexing
get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true)
set(value) {
settings.edit {
putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value)
}
}
var proxyHost
get() = proxySettings.getString(PreferenceKeys.PROXY_HOST)
set(value) {
proxySettings.edit {
putString(PreferenceKeys.PROXY_HOST, value)
}
}
var proxyPort
get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1)
set(value) {
proxySettings.edit {
putInt(PreferenceKeys.PROXY_PORT, value)
}
}
var proxyUsername
get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
set(value) {
proxySettings.edit {
putString(PreferenceKeys.PROXY_USERNAME, value)
}
}
var proxyPassword
get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD)
set(value) {
proxySettings.edit {
putString(PreferenceKeys.PROXY_PASSWORD, value)
}
}
var rebaseOnPull
get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true)
set(value) {
settings.edit {
putBoolean(PreferenceKeys.REBASE_ON_PULL, value)
}
}
sealed class UpdateConnectionSettingsResult {
class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) : UpdateConnectionSettingsResult()
object Valid : UpdateConnectionSettingsResult()
object FailedToParseUrl : UpdateConnectionSettingsResult()
var authMode
get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
private set(value) {
settings.edit { putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) }
}
fun updateConnectionSettingsIfValid(newAuthMode: AuthMode, newUrl: String, newBranch: String): UpdateConnectionSettingsResult {
val parsedUrl = runCatching {
URIish(newUrl)
}.getOrElse {
return UpdateConnectionSettingsResult.FailedToParseUrl
}
val newProtocol = when (parsedUrl.scheme) {
in listOf("http", "https") -> Protocol.Https
in listOf("ssh", null) -> Protocol.Ssh
else -> return UpdateConnectionSettingsResult.FailedToParseUrl
}
if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank())
return UpdateConnectionSettingsResult.MissingUsername(newProtocol)
val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password)
val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey)
when {
newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> {
return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth)
}
newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> {
return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth)
}
}
url = newUrl
authMode = newAuthMode
branch = newBranch
return UpdateConnectionSettingsResult.Valid
var 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()
}
/**
* Deletes a previously saved SSH host key
*/
fun clearSavedHostKey() {
File(hostKeyPath).delete()
var authorName
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
set(value) {
settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) }
}
/**
* Returns true if a host key was previously saved
*/
fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists()
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 {
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
fun runMigrations(context: Context) {
val sharedPrefs = context.sharedPrefs
migrateToGitUrlBasedConfig(sharedPrefs)
migrateToHideAll(sharedPrefs)
migrateToSshKey(context, sharedPrefs)
migrateToClipboardHistory(sharedPrefs)
val sharedPrefs = context.sharedPrefs
migrateToGitUrlBasedConfig(sharedPrefs)
migrateToHideAll(sharedPrefs)
migrateToSshKey(context, sharedPrefs)
migrateToClipboardHistory(sharedPrefs)
}
private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) {
val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER)
?: return
i { "Migrating to URL-based Git config" }
val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: ""
val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: ""
val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: ""
val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) ?: return
i { "Migrating to URL-based Git config" }
val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: ""
val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: ""
val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: ""
val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
// Whether we need the leading ssh:// depends on the use of a custom port.
val hostnamePart = serverHostname.removePrefix("ssh://")
val url = when (protocol) {
Protocol.Ssh -> {
val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@"
val portPart =
if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort"
if (portPart.isEmpty()) {
"$userPart$hostnamePart:$serverPath"
} else {
// Only absolute paths are supported with custom ports.
if (!serverPath.startsWith('/'))
null
else
// We have to specify the ssh scheme as this is the only way to pass a custom
// port.
"ssh://$userPart$hostnamePart$portPart$serverPath"
}
}
Protocol.Https -> {
val portPart =
if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort"
val pathPart = serverPath.trimStart('/', ':')
val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart"
val url = when {
urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme
urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https")
else -> "https://$urlWithFreeEntryScheme"
}
runCatching {
if (URI(url).rawAuthority != null)
url
else
null
}.get()
// Whether we need the leading ssh:// depends on the use of a custom port.
val hostnamePart = serverHostname.removePrefix("ssh://")
val url =
when (protocol) {
Protocol.Ssh -> {
val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@"
val portPart = if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort"
if (portPart.isEmpty()) {
"$userPart$hostnamePart:$serverPath"
} else {
// Only absolute paths are supported with custom ports.
if (!serverPath.startsWith('/')) null
else
// We have to specify the ssh scheme as this is the only way to pass a custom
// port.
"ssh://$userPart$hostnamePart$portPart$serverPath"
}
}
Protocol.Https -> {
val portPart = if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort"
val pathPart = serverPath.trimStart('/', ':')
val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart"
val url =
when {
urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme
urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https")
else -> "https://$urlWithFreeEntryScheme"
}
runCatching { if (URI(url).rawAuthority != null) url else null }.get()
}
}
sharedPrefs.edit {
remove(PreferenceKeys.GIT_REMOTE_LOCATION)
remove(PreferenceKeys.GIT_REMOTE_PORT)
remove(PreferenceKeys.GIT_REMOTE_SERVER)
remove(PreferenceKeys.GIT_REMOTE_USERNAME)
remove(PreferenceKeys.GIT_REMOTE_PROTOCOL)
}
if (url == null || GitSettings.updateConnectionSettingsIfValid(
newAuthMode = GitSettings.authMode,
newUrl = url,
newBranch = GitSettings.branch) != GitSettings.UpdateConnectionSettingsResult.Valid) {
e { "Failed to migrate to URL-based Git config, generated URL is invalid" }
}
sharedPrefs.edit {
remove(PreferenceKeys.GIT_REMOTE_LOCATION)
remove(PreferenceKeys.GIT_REMOTE_PORT)
remove(PreferenceKeys.GIT_REMOTE_SERVER)
remove(PreferenceKeys.GIT_REMOTE_USERNAME)
remove(PreferenceKeys.GIT_REMOTE_PROTOCOL)
}
if (url == null ||
GitSettings.updateConnectionSettingsIfValid(
newAuthMode = GitSettings.authMode,
newUrl = url,
newBranch = GitSettings.branch
) != GitSettings.UpdateConnectionSettingsResult.Valid
) {
e { "Failed to migrate to URL-based Git config, generated URL is invalid" }
}
}
private fun migrateToHideAll(sharedPrefs: SharedPreferences) {
sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return
val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
sharedPrefs.edit {
remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS)
putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden)
}
sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return
val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
sharedPrefs.edit {
remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS)
putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden)
}
}
private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) {
val privateKeyFile = File(context.filesDir, ".ssh_key")
if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) &&
!SshKey.exists &&
privateKeyFile.exists()) {
// Currently uses a private key imported or generated with an old version of Password Store.
// Generated keys come with a public key which the user should still be able to view after
// the migration (not possible for regular imported keys), hence the special case.
val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
SshKey.useLegacyKey(isGeneratedKey)
sharedPrefs.edit {
remove(PreferenceKeys.USE_GENERATED_KEY)
}
}
val privateKeyFile = File(context.filesDir, ".ssh_key")
if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && !SshKey.exists && privateKeyFile.exists()) {
// Currently uses a private key imported or generated with an old version of Password Store.
// Generated keys come with a public key which the user should still be able to view after
// the migration (not possible for regular imported keys), hence the special case.
val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
SshKey.useLegacyKey(isGeneratedKey)
sharedPrefs.edit { remove(PreferenceKeys.USE_GENERATED_KEY) }
}
}
private fun migrateToClipboardHistory(sharedPrefs: SharedPreferences) {
if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) {
sharedPrefs.edit {
putBoolean(
PreferenceKeys.CLEAR_CLIPBOARD_HISTORY,
sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
)
remove(PreferenceKeys.CLEAR_CLIPBOARD_20X)
}
if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) {
sharedPrefs.edit {
putBoolean(
PreferenceKeys.CLEAR_CLIPBOARD_HISTORY,
sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
)
remove(PreferenceKeys.CLEAR_CLIPBOARD_20X)
}
}
}

View file

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

View file

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

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