all: reformat with ktfmt
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
parent
be31ae37f4
commit
774fda83ac
145 changed files with 12016 additions and 12490 deletions
|
@ -4,7 +4,7 @@
|
||||||
<option name="LINE_SEPARATOR" value=" " />
|
<option name="LINE_SEPARATOR" value=" " />
|
||||||
<option name="WRAP_WHEN_TYPING_REACHES_RIGHT_MARGIN" value="true" />
|
<option name="WRAP_WHEN_TYPING_REACHES_RIGHT_MARGIN" value="true" />
|
||||||
<option name="FORMATTER_TAGS_ENABLED" value="true" />
|
<option name="FORMATTER_TAGS_ENABLED" value="true" />
|
||||||
<option name="SOFT_MARGINS" value="100" />
|
<option name="SOFT_MARGINS" value="120" />
|
||||||
<option name="DO_NOT_FORMAT">
|
<option name="DO_NOT_FORMAT">
|
||||||
<list>
|
<list>
|
||||||
<fileSet type="namedScope" name="third_party" pattern="src[Android-Password-Store.app]:mozilla.components.lib.publicsuffixlist..*" />
|
<fileSet type="namedScope" name="third_party" pattern="src[Android-Password-Store.app]:mozilla.components.lib.publicsuffixlist..*" />
|
||||||
|
@ -161,7 +161,7 @@
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="kotlin">
|
<codeStyleSettings language="kotlin">
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
<option name="RIGHT_MARGIN" value="100" />
|
<option name="RIGHT_MARGIN" value="120" />
|
||||||
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="0" />
|
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="0" />
|
||||||
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
|
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
|
||||||
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
|
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
|
||||||
|
@ -183,4 +183,4 @@
|
||||||
</indentOptions>
|
</indentOptions>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
</code_scheme>
|
</code_scheme>
|
||||||
</component>
|
</component>
|
||||||
|
|
|
@ -5,104 +5,100 @@
|
||||||
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
kotlin("android")
|
kotlin("android")
|
||||||
`versioning-plugin`
|
`versioning-plugin`
|
||||||
`aps-plugin`
|
`aps-plugin`
|
||||||
`crowdin-plugin`
|
`crowdin-plugin`
|
||||||
}
|
}
|
||||||
|
|
||||||
configure<CrowdinExtension> {
|
configure<CrowdinExtension> { projectName = "android-password-store" }
|
||||||
projectName = "android-password-store"
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
if (isSnapshot()) {
|
if (isSnapshot()) {
|
||||||
applicationVariants.all {
|
applicationVariants.all {
|
||||||
outputs.all {
|
outputs.all {
|
||||||
(this as BaseVariantOutputImpl).outputFileName = "aps-${flavorName}_$versionName.apk"
|
(this as BaseVariantOutputImpl).outputFileName = "aps-${flavorName}_$versionName.apk"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "dev.msfjarvis.aps"
|
applicationId = "dev.msfjarvis.aps"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
lintOptions {
|
lintOptions {
|
||||||
isAbortOnError = true
|
isAbortOnError = true
|
||||||
isCheckReleaseBuilds = false
|
isCheckReleaseBuilds = false
|
||||||
disable("MissingTranslation", "PluralsCandidate", "ImpliedQuantity")
|
disable("MissingTranslation", "PluralsCandidate", "ImpliedQuantity")
|
||||||
}
|
}
|
||||||
|
|
||||||
flavorDimensions("free")
|
flavorDimensions("free")
|
||||||
productFlavors {
|
productFlavors {
|
||||||
create("free") {
|
create("free") {}
|
||||||
}
|
create("nonFree") {}
|
||||||
create("nonFree") {
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly(Dependencies.AndroidX.annotation)
|
compileOnly(Dependencies.AndroidX.annotation)
|
||||||
implementation(project(":autofill-parser"))
|
implementation(project(":autofill-parser"))
|
||||||
implementation(project(":openpgp-ktx"))
|
implementation(project(":openpgp-ktx"))
|
||||||
implementation(Dependencies.AndroidX.activity_ktx)
|
implementation(Dependencies.AndroidX.activity_ktx)
|
||||||
implementation(Dependencies.AndroidX.appcompat)
|
implementation(Dependencies.AndroidX.appcompat)
|
||||||
implementation(Dependencies.AndroidX.autofill)
|
implementation(Dependencies.AndroidX.autofill)
|
||||||
implementation(Dependencies.AndroidX.biometric_ktx)
|
implementation(Dependencies.AndroidX.biometric_ktx)
|
||||||
implementation(Dependencies.AndroidX.constraint_layout)
|
implementation(Dependencies.AndroidX.constraint_layout)
|
||||||
implementation(Dependencies.AndroidX.core_ktx)
|
implementation(Dependencies.AndroidX.core_ktx)
|
||||||
implementation(Dependencies.AndroidX.documentfile)
|
implementation(Dependencies.AndroidX.documentfile)
|
||||||
implementation(Dependencies.AndroidX.fragment_ktx)
|
implementation(Dependencies.AndroidX.fragment_ktx)
|
||||||
implementation(Dependencies.AndroidX.lifecycle_common)
|
implementation(Dependencies.AndroidX.lifecycle_common)
|
||||||
implementation(Dependencies.AndroidX.lifecycle_livedata_ktx)
|
implementation(Dependencies.AndroidX.lifecycle_livedata_ktx)
|
||||||
implementation(Dependencies.AndroidX.lifecycle_viewmodel_ktx)
|
implementation(Dependencies.AndroidX.lifecycle_viewmodel_ktx)
|
||||||
implementation(Dependencies.AndroidX.material)
|
implementation(Dependencies.AndroidX.material)
|
||||||
implementation(Dependencies.AndroidX.preference)
|
implementation(Dependencies.AndroidX.preference)
|
||||||
implementation(Dependencies.AndroidX.recycler_view)
|
implementation(Dependencies.AndroidX.recycler_view)
|
||||||
implementation(Dependencies.AndroidX.recycler_view_selection)
|
implementation(Dependencies.AndroidX.recycler_view_selection)
|
||||||
implementation(Dependencies.AndroidX.security)
|
implementation(Dependencies.AndroidX.security)
|
||||||
implementation(Dependencies.AndroidX.swiperefreshlayout)
|
implementation(Dependencies.AndroidX.swiperefreshlayout)
|
||||||
|
|
||||||
implementation(Dependencies.Kotlin.Coroutines.android)
|
implementation(Dependencies.Kotlin.Coroutines.android)
|
||||||
implementation(Dependencies.Kotlin.Coroutines.core)
|
implementation(Dependencies.Kotlin.Coroutines.core)
|
||||||
|
|
||||||
implementation(Dependencies.FirstParty.zxing_android_embedded)
|
implementation(Dependencies.FirstParty.zxing_android_embedded)
|
||||||
|
|
||||||
implementation(Dependencies.ThirdParty.bouncycastle)
|
implementation(Dependencies.ThirdParty.bouncycastle)
|
||||||
implementation(Dependencies.ThirdParty.commons_codec)
|
implementation(Dependencies.ThirdParty.commons_codec)
|
||||||
implementation(Dependencies.ThirdParty.eddsa)
|
implementation(Dependencies.ThirdParty.eddsa)
|
||||||
implementation(Dependencies.ThirdParty.fastscroll)
|
implementation(Dependencies.ThirdParty.fastscroll)
|
||||||
implementation(Dependencies.ThirdParty.jgit) {
|
implementation(Dependencies.ThirdParty.jgit) {
|
||||||
exclude(group = "org.apache.httpcomponents", module = "httpclient")
|
exclude(group = "org.apache.httpcomponents", module = "httpclient")
|
||||||
}
|
}
|
||||||
implementation(Dependencies.ThirdParty.kotlin_result)
|
implementation(Dependencies.ThirdParty.kotlin_result)
|
||||||
implementation(Dependencies.ThirdParty.modern_android_prefs)
|
implementation(Dependencies.ThirdParty.modern_android_prefs)
|
||||||
implementation(Dependencies.ThirdParty.plumber)
|
implementation(Dependencies.ThirdParty.plumber)
|
||||||
implementation(Dependencies.ThirdParty.ssh_auth)
|
implementation(Dependencies.ThirdParty.ssh_auth)
|
||||||
implementation(Dependencies.ThirdParty.sshj)
|
implementation(Dependencies.ThirdParty.sshj)
|
||||||
implementation(Dependencies.ThirdParty.timber)
|
implementation(Dependencies.ThirdParty.timber)
|
||||||
implementation(Dependencies.ThirdParty.timberkt)
|
implementation(Dependencies.ThirdParty.timberkt)
|
||||||
|
|
||||||
if (isSnapshot()) {
|
if (isSnapshot()) {
|
||||||
implementation(Dependencies.ThirdParty.leakcanary)
|
implementation(Dependencies.ThirdParty.leakcanary)
|
||||||
implementation(Dependencies.ThirdParty.whatthestack)
|
implementation(Dependencies.ThirdParty.whatthestack)
|
||||||
} else {
|
} else {
|
||||||
debugImplementation(Dependencies.ThirdParty.leakcanary)
|
debugImplementation(Dependencies.ThirdParty.leakcanary)
|
||||||
debugImplementation(Dependencies.ThirdParty.whatthestack)
|
debugImplementation(Dependencies.ThirdParty.whatthestack)
|
||||||
}
|
}
|
||||||
|
|
||||||
"nonFreeImplementation"(Dependencies.NonFree.google_play_auth_api_phone)
|
"nonFreeImplementation"(Dependencies.NonFree.google_play_auth_api_phone)
|
||||||
|
|
||||||
// Testing-only dependencies
|
// Testing-only dependencies
|
||||||
androidTestImplementation(Dependencies.Testing.junit)
|
androidTestImplementation(Dependencies.Testing.junit)
|
||||||
androidTestImplementation(Dependencies.Testing.kotlin_test_junit)
|
androidTestImplementation(Dependencies.Testing.kotlin_test_junit)
|
||||||
androidTestImplementation(Dependencies.Testing.AndroidX.runner)
|
androidTestImplementation(Dependencies.Testing.AndroidX.runner)
|
||||||
androidTestImplementation(Dependencies.Testing.AndroidX.rules)
|
androidTestImplementation(Dependencies.Testing.AndroidX.rules)
|
||||||
|
|
||||||
testImplementation(Dependencies.Testing.junit)
|
testImplementation(Dependencies.Testing.junit)
|
||||||
testImplementation(Dependencies.Testing.kotlin_test_junit)
|
testImplementation(Dependencies.Testing.kotlin_test_junit)
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,98 +18,105 @@ import org.junit.Test
|
||||||
|
|
||||||
class PasswordEntryAndroidTest {
|
class PasswordEntryAndroidTest {
|
||||||
|
|
||||||
private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder())
|
private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder())
|
||||||
|
|
||||||
@Test fun testGetPassword() {
|
@Test
|
||||||
assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
|
fun testGetPassword() {
|
||||||
assertEquals("fooooo", makeEntry("fooooo\nbla").password)
|
assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
|
||||||
assertEquals("fooooo", makeEntry("fooooo\n").password)
|
assertEquals("fooooo", makeEntry("fooooo\nbla").password)
|
||||||
assertEquals("fooooo", makeEntry("fooooo").password)
|
assertEquals("fooooo", makeEntry("fooooo\n").password)
|
||||||
assertEquals("", makeEntry("\nblubb\n").password)
|
assertEquals("fooooo", makeEntry("fooooo").password)
|
||||||
assertEquals("", makeEntry("\nblubb").password)
|
assertEquals("", makeEntry("\nblubb\n").password)
|
||||||
assertEquals("", makeEntry("\n").password)
|
assertEquals("", makeEntry("\nblubb").password)
|
||||||
assertEquals("", makeEntry("").password)
|
assertEquals("", makeEntry("\n").password)
|
||||||
|
assertEquals("", makeEntry("").password)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetExtraContent() {
|
||||||
|
assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent)
|
||||||
|
assertEquals("bla", makeEntry("fooooo\nbla").extraContent)
|
||||||
|
assertEquals("", makeEntry("fooooo\n").extraContent)
|
||||||
|
assertEquals("", makeEntry("fooooo").extraContent)
|
||||||
|
assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent)
|
||||||
|
assertEquals("blubb", makeEntry("\nblubb").extraContent)
|
||||||
|
assertEquals("", makeEntry("\n").extraContent)
|
||||||
|
assertEquals("", makeEntry("").extraContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGetUsername() {
|
||||||
|
for (field in PasswordEntry.USERNAME_FIELDS) {
|
||||||
|
assertEquals("username", makeEntry("\n$field username").username)
|
||||||
|
assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username)
|
||||||
}
|
}
|
||||||
|
assertEquals("username", makeEntry("secret\nextra\nlogin: username\ncontent\n").username)
|
||||||
|
assertEquals("username", makeEntry("\nextra\nusername: username\ncontent\n").username)
|
||||||
|
assertEquals("username", makeEntry("\nUSERNaMe: username\ncontent\n").username)
|
||||||
|
assertEquals("username", makeEntry("\nlogin: username").username)
|
||||||
|
assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username)
|
||||||
|
assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username)
|
||||||
|
assertEquals("username", makeEntry("\nLOGiN:username").username)
|
||||||
|
assertNull(makeEntry("secret\nextra\ncontent\n").username)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun testGetExtraContent() {
|
@Test
|
||||||
assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent)
|
fun testHasUsername() {
|
||||||
assertEquals("bla", makeEntry("fooooo\nbla").extraContent)
|
assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername())
|
||||||
assertEquals("", makeEntry("fooooo\n").extraContent)
|
assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername())
|
||||||
assertEquals("", makeEntry("fooooo").extraContent)
|
assertFalse(makeEntry("secret\nlogin failed\n").hasUsername())
|
||||||
assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent)
|
assertFalse(makeEntry("\n").hasUsername())
|
||||||
assertEquals("blubb", makeEntry("\nblubb").extraContent)
|
assertFalse(makeEntry("").hasUsername())
|
||||||
assertEquals("", makeEntry("\n").extraContent)
|
}
|
||||||
assertEquals("", makeEntry("").extraContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun testGetUsername() {
|
@Test
|
||||||
for (field in PasswordEntry.USERNAME_FIELDS) {
|
fun testGeneratesOtpFromTotpUri() {
|
||||||
assertEquals("username", makeEntry("\n$field username").username)
|
val entry = makeEntry("secret\nextra\n$TOTP_URI")
|
||||||
assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username)
|
assertTrue(entry.hasTotp())
|
||||||
}
|
val code =
|
||||||
assertEquals(
|
Otp.calculateCode(
|
||||||
"username",
|
entry.totpSecret!!,
|
||||||
makeEntry("secret\nextra\nlogin: username\ncontent\n").username)
|
// The hardcoded date value allows this test to stay reproducible.
|
||||||
assertEquals(
|
Date(8640000).time / (1000 * entry.totpPeriod),
|
||||||
"username",
|
entry.totpAlgorithm,
|
||||||
makeEntry("\nextra\nusername: username\ncontent\n").username)
|
entry.digits
|
||||||
assertEquals(
|
)
|
||||||
"username", makeEntry("\nUSERNaMe: username\ncontent\n").username)
|
.get()
|
||||||
assertEquals("username", makeEntry("\nlogin: username").username)
|
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||||
assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username)
|
assertEquals(entry.digits.toInt(), code.length)
|
||||||
assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username)
|
assertEquals("545293", code)
|
||||||
assertEquals("username", makeEntry("\nLOGiN:username").username)
|
}
|
||||||
assertNull(makeEntry("secret\nextra\ncontent\n").username)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun testHasUsername() {
|
@Test
|
||||||
assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername())
|
fun testGeneratesOtpWithOnlyUriInFile() {
|
||||||
assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername())
|
val entry = makeEntry(TOTP_URI)
|
||||||
assertFalse(makeEntry("secret\nlogin failed\n").hasUsername())
|
assertTrue(entry.password.isEmpty())
|
||||||
assertFalse(makeEntry("\n").hasUsername())
|
assertTrue(entry.hasTotp())
|
||||||
assertFalse(makeEntry("").hasUsername())
|
val code =
|
||||||
}
|
Otp.calculateCode(
|
||||||
|
entry.totpSecret!!,
|
||||||
|
// The hardcoded date value allows this test to stay reproducible.
|
||||||
|
Date(8640000).time / (1000 * entry.totpPeriod),
|
||||||
|
entry.totpAlgorithm,
|
||||||
|
entry.digits
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||||
|
assertEquals(entry.digits.toInt(), code.length)
|
||||||
|
assertEquals("545293", code)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun testGeneratesOtpFromTotpUri() {
|
@Test
|
||||||
val entry = makeEntry("secret\nextra\n$TOTP_URI")
|
fun testOnlyLooksForUriInFirstLine() {
|
||||||
assertTrue(entry.hasTotp())
|
val entry = makeEntry("id:\n$TOTP_URI")
|
||||||
val code = Otp.calculateCode(
|
assertTrue(entry.password.isNotEmpty())
|
||||||
entry.totpSecret!!,
|
assertTrue(entry.hasTotp())
|
||||||
// The hardcoded date value allows this test to stay reproducible.
|
assertFalse(entry.hasUsername())
|
||||||
Date(8640000).time / (1000 * entry.totpPeriod),
|
}
|
||||||
entry.totpAlgorithm,
|
|
||||||
entry.digits
|
|
||||||
).get()
|
|
||||||
assertNotNull(code) { "Generated OTP cannot be null" }
|
|
||||||
assertEquals(entry.digits.toInt(), code.length)
|
|
||||||
assertEquals("545293", code)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun testGeneratesOtpWithOnlyUriInFile() {
|
companion object {
|
||||||
val entry = makeEntry(TOTP_URI)
|
|
||||||
assertTrue(entry.password.isEmpty())
|
|
||||||
assertTrue(entry.hasTotp())
|
|
||||||
val code = Otp.calculateCode(
|
|
||||||
entry.totpSecret!!,
|
|
||||||
// The hardcoded date value allows this test to stay reproducible.
|
|
||||||
Date(8640000).time / (1000 * entry.totpPeriod),
|
|
||||||
entry.totpAlgorithm,
|
|
||||||
entry.digits
|
|
||||||
).get()
|
|
||||||
assertNotNull(code) { "Generated OTP cannot be null" }
|
|
||||||
assertEquals(entry.digits.toInt(), code.length)
|
|
||||||
assertEquals("545293", code)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test fun testOnlyLooksForUriInFirstLine() {
|
const val TOTP_URI =
|
||||||
val entry = makeEntry("id:\n$TOTP_URI")
|
"otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
|
||||||
assertTrue(entry.password.isNotEmpty())
|
}
|
||||||
assertTrue(entry.hasTotp())
|
|
||||||
assertFalse(entry.hasUsername())
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,102 +19,103 @@ import org.junit.Test
|
||||||
|
|
||||||
class MigrationsTest {
|
class MigrationsTest {
|
||||||
|
|
||||||
private fun checkOldKeysAreRemoved(context: Context) = with(context.sharedPrefs) {
|
private fun checkOldKeysAreRemoved(context: Context) =
|
||||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT))
|
with(context.sharedPrefs) {
|
||||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME))
|
assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT))
|
||||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER))
|
assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME))
|
||||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION))
|
assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER))
|
||||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
|
assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION))
|
||||||
|
assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun verifySshWithCustomPortMigration() {
|
fun verifySshWithCustomPortMigration() {
|
||||||
val context = Application.instance.applicationContext
|
val context = Application.instance.applicationContext
|
||||||
context.sharedPrefs.edit {
|
context.sharedPrefs.edit {
|
||||||
clear()
|
clear()
|
||||||
putString(PreferenceKeys.GIT_REMOTE_PORT, "2200")
|
putString(PreferenceKeys.GIT_REMOTE_PORT, "2200")
|
||||||
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
||||||
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
|
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
|
||||||
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
|
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
|
||||||
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
|
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
|
||||||
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.Password.pref)
|
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.Password.pref)
|
||||||
}
|
|
||||||
runMigrations(context)
|
|
||||||
checkOldKeysAreRemoved(context)
|
|
||||||
assertEquals(
|
|
||||||
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
|
||||||
"ssh://msfjarvis@192.168.0.102:2200/mnt/disk3/pass-repo"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
runMigrations(context)
|
||||||
|
checkOldKeysAreRemoved(context)
|
||||||
|
assertEquals(
|
||||||
|
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
||||||
|
"ssh://msfjarvis@192.168.0.102:2200/mnt/disk3/pass-repo"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun verifySshWithDefaultPortMigration() {
|
fun verifySshWithDefaultPortMigration() {
|
||||||
val context = Application.instance.applicationContext
|
val context = Application.instance.applicationContext
|
||||||
context.sharedPrefs.edit {
|
context.sharedPrefs.edit {
|
||||||
clear()
|
clear()
|
||||||
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
||||||
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
|
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
|
||||||
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
|
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
|
||||||
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
|
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
|
||||||
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.SshKey.pref)
|
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.SshKey.pref)
|
||||||
}
|
|
||||||
runMigrations(context)
|
|
||||||
checkOldKeysAreRemoved(context)
|
|
||||||
assertEquals(
|
|
||||||
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
|
||||||
"msfjarvis@192.168.0.102:/mnt/disk3/pass-repo"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
runMigrations(context)
|
||||||
|
checkOldKeysAreRemoved(context)
|
||||||
|
assertEquals(
|
||||||
|
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
||||||
|
"msfjarvis@192.168.0.102:/mnt/disk3/pass-repo"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun verifyHttpsWithGitHubMigration() {
|
fun verifyHttpsWithGitHubMigration() {
|
||||||
val context = Application.instance.applicationContext
|
val context = Application.instance.applicationContext
|
||||||
context.sharedPrefs.edit {
|
context.sharedPrefs.edit {
|
||||||
clear()
|
clear()
|
||||||
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
||||||
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "Android-Password-Store/pass-test")
|
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "Android-Password-Store/pass-test")
|
||||||
putString(PreferenceKeys.GIT_REMOTE_SERVER, "github.com")
|
putString(PreferenceKeys.GIT_REMOTE_SERVER, "github.com")
|
||||||
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Https.pref)
|
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Https.pref)
|
||||||
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.None.pref)
|
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.None.pref)
|
||||||
}
|
|
||||||
runMigrations(context)
|
|
||||||
checkOldKeysAreRemoved(context)
|
|
||||||
assertEquals(
|
|
||||||
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
|
||||||
"https://github.com/Android-Password-Store/pass-test"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
runMigrations(context)
|
||||||
|
checkOldKeysAreRemoved(context)
|
||||||
|
assertEquals(
|
||||||
|
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
||||||
|
"https://github.com/Android-Password-Store/pass-test"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun verifyHiddenFoldersMigrationIfDisabled() {
|
fun verifyHiddenFoldersMigrationIfDisabled() {
|
||||||
val context = Application.instance.applicationContext
|
val context = Application.instance.applicationContext
|
||||||
context.sharedPrefs.edit { clear() }
|
context.sharedPrefs.edit { clear() }
|
||||||
runMigrations(context)
|
runMigrations(context)
|
||||||
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true))
|
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true))
|
||||||
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
|
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun verifyHiddenFoldersMigrationIfEnabled() {
|
fun verifyHiddenFoldersMigrationIfEnabled() {
|
||||||
val context = Application.instance.applicationContext
|
val context = Application.instance.applicationContext
|
||||||
context.sharedPrefs.edit {
|
context.sharedPrefs.edit {
|
||||||
clear()
|
clear()
|
||||||
putBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true)
|
putBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true)
|
||||||
}
|
|
||||||
runMigrations(context)
|
|
||||||
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false))
|
|
||||||
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
|
|
||||||
}
|
}
|
||||||
|
runMigrations(context)
|
||||||
|
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false))
|
||||||
|
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun verifyClearClipboardHistoryMigration() {
|
fun verifyClearClipboardHistoryMigration() {
|
||||||
val context = Application.instance.applicationContext
|
val context = Application.instance.applicationContext
|
||||||
context.sharedPrefs.edit {
|
context.sharedPrefs.edit {
|
||||||
clear()
|
clear()
|
||||||
putBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, true)
|
putBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, true)
|
||||||
}
|
|
||||||
runMigrations(context)
|
|
||||||
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false))
|
|
||||||
assertFalse(context.sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X))
|
|
||||||
}
|
}
|
||||||
|
runMigrations(context)
|
||||||
|
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false))
|
||||||
|
assertFalse(context.sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,36 +10,40 @@ import org.junit.Test
|
||||||
|
|
||||||
class UriTotpFinderTest {
|
class UriTotpFinderTest {
|
||||||
|
|
||||||
private val totpFinder = UriTotpFinder()
|
private val totpFinder = UriTotpFinder()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun findSecret() {
|
fun findSecret() {
|
||||||
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI))
|
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI))
|
||||||
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"))
|
assertEquals(
|
||||||
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT))
|
"HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ",
|
||||||
}
|
totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")
|
||||||
|
)
|
||||||
|
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun findDigits() {
|
fun findDigits() {
|
||||||
assertEquals("12", totpFinder.findDigits(TOTP_URI))
|
assertEquals("12", totpFinder.findDigits(TOTP_URI))
|
||||||
assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT))
|
assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun findPeriod() {
|
fun findPeriod() {
|
||||||
assertEquals(25, totpFinder.findPeriod(TOTP_URI))
|
assertEquals(25, totpFinder.findPeriod(TOTP_URI))
|
||||||
assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT))
|
assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun findAlgorithm() {
|
fun findAlgorithm() {
|
||||||
assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI))
|
assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI))
|
||||||
assertEquals("SHA256", totpFinder.findAlgorithm(PASS_FILE_CONTENT))
|
assertEquals("SHA256", totpFinder.findAlgorithm(PASS_FILE_CONTENT))
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25"
|
const val TOTP_URI =
|
||||||
const val PASS_FILE_CONTENT = "password\n$TOTP_URI"
|
"otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25"
|
||||||
}
|
const val PASS_FILE_CONTENT = "password\n$TOTP_URI"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,40 +10,46 @@ import kotlin.test.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
private infix fun String.matchedForDomain(domain: String) =
|
private infix fun String.matchedForDomain(domain: String) =
|
||||||
SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true
|
SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true
|
||||||
|
|
||||||
class StrictDomainRegexTest {
|
class StrictDomainRegexTest {
|
||||||
|
|
||||||
@Test fun acceptsLiteralDomain() {
|
@Test
|
||||||
assertTrue("work/example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
fun acceptsLiteralDomain() {
|
||||||
assertTrue("example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
assertTrue("work/example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||||
assertTrue("example.org.gpg" matchedForDomain "example.org")
|
assertTrue("example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||||
}
|
assertTrue("example.org.gpg" matchedForDomain "example.org")
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun acceptsSubdomains() {
|
@Test
|
||||||
assertTrue("work/www.example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
fun acceptsSubdomains() {
|
||||||
assertTrue("www2.example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
assertTrue("work/www.example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||||
assertTrue("www.login.example.org.gpg" matchedForDomain "example.org")
|
assertTrue("www2.example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||||
}
|
assertTrue("www.login.example.org.gpg" matchedForDomain "example.org")
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun rejectsPhishingAttempts() {
|
@Test
|
||||||
assertFalse("example.org.gpg" matchedForDomain "xample.org")
|
fun rejectsPhishingAttempts() {
|
||||||
assertFalse("login.example.org.gpg" matchedForDomain "xample.org")
|
assertFalse("example.org.gpg" matchedForDomain "xample.org")
|
||||||
assertFalse("example.org/john.doe@exmple.org.gpg" matchedForDomain "xample.org")
|
assertFalse("login.example.org.gpg" matchedForDomain "xample.org")
|
||||||
assertFalse("example.org.gpg" matchedForDomain "e/xample.org")
|
assertFalse("example.org/john.doe@exmple.org.gpg" matchedForDomain "xample.org")
|
||||||
}
|
assertFalse("example.org.gpg" matchedForDomain "e/xample.org")
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun rejectNonGpgComponentMatches() {
|
@Test
|
||||||
assertFalse("work/example.org" matchedForDomain "example.org")
|
fun rejectNonGpgComponentMatches() {
|
||||||
}
|
assertFalse("work/example.org" matchedForDomain "example.org")
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun rejectsEmailAddresses() {
|
@Test
|
||||||
assertFalse("work/notexample.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
fun rejectsEmailAddresses() {
|
||||||
assertFalse("work/notexample.org/john.doe@www.example.org.gpg" matchedForDomain "example.org")
|
assertFalse("work/notexample.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||||
assertFalse("work/john.doe@www.example.org/foo.org" matchedForDomain "example.org")
|
assertFalse("work/notexample.org/john.doe@www.example.org.gpg" matchedForDomain "example.org")
|
||||||
}
|
assertFalse("work/john.doe@www.example.org/foo.org" matchedForDomain "example.org")
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun rejectsPathSeparators() {
|
@Test
|
||||||
assertNull(SearchableRepositoryViewModel.generateStrictDomainRegex("ex/ample.org"))
|
fun rejectsPathSeparators() {
|
||||||
}
|
assertNull(SearchableRepositoryViewModel.generateStrictDomainRegex("ex/ample.org"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,14 +14,14 @@ import androidx.appcompat.app.AppCompatActivity
|
||||||
@Suppress("UNUSED_PARAMETER")
|
@Suppress("UNUSED_PARAMETER")
|
||||||
class AutofillSmsActivity : AppCompatActivity() {
|
class AutofillSmsActivity : AppCompatActivity() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun shouldOfferFillFromSms(context: Context): Boolean {
|
fun shouldOfferFillFromSms(context: Context): Boolean {
|
||||||
return false
|
return false
|
||||||
}
|
|
||||||
|
|
||||||
fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender {
|
|
||||||
throw NotImplementedError("Filling OTPs from SMS requires non-free dependencies")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender {
|
||||||
|
throw NotImplementedError("Filling OTPs from SMS requires non-free dependencies")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,45 +22,45 @@ import dev.msfjarvis.aps.util.settings.runMigrations
|
||||||
@Suppress("Unused")
|
@Suppress("Unused")
|
||||||
class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener {
|
class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
|
||||||
private val prefs by lazy { sharedPrefs }
|
private val prefs by lazy { sharedPrefs }
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
if (BuildConfig.ENABLE_DEBUG_FEATURES ||
|
if (BuildConfig.ENABLE_DEBUG_FEATURES || prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) {
|
||||||
prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) {
|
plant(DebugTree())
|
||||||
plant(DebugTree())
|
|
||||||
}
|
|
||||||
prefs.registerOnSharedPreferenceChangeListener(this)
|
|
||||||
setNightMode()
|
|
||||||
setUpBouncyCastleForSshj()
|
|
||||||
runMigrations(applicationContext)
|
|
||||||
ProxyUtils.setDefaultProxy()
|
|
||||||
}
|
}
|
||||||
|
prefs.registerOnSharedPreferenceChangeListener(this)
|
||||||
|
setNightMode()
|
||||||
|
setUpBouncyCastleForSshj()
|
||||||
|
runMigrations(applicationContext)
|
||||||
|
ProxyUtils.setDefaultProxy()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onTerminate() {
|
override fun onTerminate() {
|
||||||
prefs.unregisterOnSharedPreferenceChangeListener(this)
|
prefs.unregisterOnSharedPreferenceChangeListener(this)
|
||||||
super.onTerminate()
|
super.onTerminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) {
|
||||||
|
if (key == PreferenceKeys.APP_THEME) {
|
||||||
|
setNightMode()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) {
|
private fun setNightMode() {
|
||||||
if (key == PreferenceKeys.APP_THEME) {
|
AppCompatDelegate.setDefaultNightMode(
|
||||||
setNightMode()
|
when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) {
|
||||||
}
|
"light" -> MODE_NIGHT_NO
|
||||||
}
|
"dark" -> MODE_NIGHT_YES
|
||||||
|
"follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM
|
||||||
|
else -> MODE_NIGHT_AUTO_BATTERY
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun setNightMode() {
|
companion object {
|
||||||
AppCompatDelegate.setDefaultNightMode(when (prefs.getString(PreferenceKeys.APP_THEME)
|
|
||||||
?: getString(R.string.app_theme_def)) {
|
|
||||||
"light" -> MODE_NIGHT_NO
|
|
||||||
"dark" -> MODE_NIGHT_YES
|
|
||||||
"follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM
|
|
||||||
else -> MODE_NIGHT_AUTO_BATTERY
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
lateinit var instance: Application
|
||||||
|
}
|
||||||
lateinit var instance: Application
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,27 +6,30 @@
|
||||||
package dev.msfjarvis.aps.data.password
|
package dev.msfjarvis.aps.data.password
|
||||||
|
|
||||||
class FieldItem(val key: String, val value: String, val action: ActionType) {
|
class FieldItem(val key: String, val value: String, val action: ActionType) {
|
||||||
enum class ActionType {
|
enum class ActionType {
|
||||||
COPY, HIDE
|
COPY,
|
||||||
|
HIDE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ItemType(val type: String) {
|
||||||
|
USERNAME("Username"),
|
||||||
|
PASSWORD("Password"),
|
||||||
|
OTP("OTP")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
// Extra helper methods
|
||||||
|
fun createOtpField(otp: String): FieldItem {
|
||||||
|
return FieldItem(ItemType.OTP.type, otp, ActionType.COPY)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ItemType(val type: String) {
|
fun createPasswordField(password: String): FieldItem {
|
||||||
USERNAME("Username"), PASSWORD("Password"), OTP("OTP")
|
return FieldItem(ItemType.PASSWORD.type, password, ActionType.HIDE)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
fun createUsernameField(username: String): FieldItem {
|
||||||
|
return FieldItem(ItemType.USERNAME.type, username, ActionType.COPY)
|
||||||
// Extra helper methods
|
|
||||||
fun createOtpField(otp: String): FieldItem {
|
|
||||||
return FieldItem(ItemType.OTP.type, otp, ActionType.COPY)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createPasswordField(password: String): FieldItem {
|
|
||||||
return FieldItem(ItemType.PASSWORD.type, password, ActionType.HIDE)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createUsernameField(username: String): FieldItem {
|
|
||||||
return FieldItem(ItemType.USERNAME.type, username, ActionType.COPY)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,178 +18,178 @@ import java.util.Date
|
||||||
*/
|
*/
|
||||||
class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) {
|
class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) {
|
||||||
|
|
||||||
val password: String
|
val password: String
|
||||||
val username: String?
|
val username: String?
|
||||||
|
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val digits: String
|
||||||
|
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpSecret: String?
|
||||||
|
val totpPeriod: Long
|
||||||
|
|
||||||
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpAlgorithm: String
|
||||||
|
val extraContent: String
|
||||||
|
val extraContentWithoutAuthData: String
|
||||||
|
val extraContentMap: Map<String, String>
|
||||||
|
|
||||||
|
constructor(os: ByteArrayOutputStream) : this(os.toString(Charsets.UTF_8.name()), UriTotpFinder())
|
||||||
|
|
||||||
|
init {
|
||||||
|
val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
|
||||||
|
password = foundPassword
|
||||||
|
extraContent = passContent.joinToString("\n")
|
||||||
|
extraContentWithoutAuthData = generateExtraContentWithoutAuthData()
|
||||||
|
extraContentMap = generateExtraContentPairs()
|
||||||
|
username = findUsername()
|
||||||
|
digits = findOtpDigits(content)
|
||||||
|
totpSecret = findTotpSecret(content)
|
||||||
|
totpPeriod = findTotpPeriod(content)
|
||||||
|
totpAlgorithm = findTotpAlgorithm(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasExtraContent(): Boolean {
|
||||||
|
return extraContent.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasExtraContentWithoutAuthData(): Boolean {
|
||||||
|
return extraContentWithoutAuthData.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasTotp(): Boolean {
|
||||||
|
return totpSecret != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasUsername(): Boolean {
|
||||||
|
return username != null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculateTotpCode(): String? {
|
||||||
|
if (totpSecret == null) return null
|
||||||
|
return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateExtraContentWithoutAuthData(): String {
|
||||||
|
var foundUsername = false
|
||||||
|
return extraContent
|
||||||
|
.lineSequence()
|
||||||
|
.filter { line ->
|
||||||
|
return@filter when {
|
||||||
|
USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> {
|
||||||
|
foundUsername = true
|
||||||
|
false
|
||||||
|
}
|
||||||
|
line.startsWith("otpauth://", ignoreCase = true) || line.startsWith("totp:", ignoreCase = true) -> {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.joinToString(separator = "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateExtraContentPairs(): Map<String, String> {
|
||||||
|
fun MutableMap<String, String>.putOrAppend(key: String, value: String) {
|
||||||
|
if (value.isEmpty()) return
|
||||||
|
val existing = this[key]
|
||||||
|
this[key] =
|
||||||
|
if (existing == null) {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
"$existing\n$value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val items = mutableMapOf<String, String>()
|
||||||
|
// Take extraContentWithoutAuthData and onEach line perform the following tasks
|
||||||
|
extraContentWithoutAuthData.lines().forEach { line ->
|
||||||
|
// Split the line on ':' and save all the parts into an array
|
||||||
|
// "ABC : DEF:GHI" --> ["ABC", "DEF", "GHI"]
|
||||||
|
val splitArray = line.split(":")
|
||||||
|
// Take the first element of the array. This will be the key for the key-value pair.
|
||||||
|
// ["ABC ", " DEF", "GHI"] -> key = "ABC"
|
||||||
|
val key = splitArray.first().trimEnd()
|
||||||
|
// Remove the first element from the array and join the rest of the string again with
|
||||||
|
// ':' as separator.
|
||||||
|
// ["ABC ", " DEF", "GHI"] -> value = "DEF:GHI"
|
||||||
|
val value = splitArray.drop(1).joinToString(":").trimStart()
|
||||||
|
|
||||||
|
if (key.isNotEmpty() && value.isNotEmpty()) {
|
||||||
|
// If both key and value are not empty, we can form a pair with this so add it to
|
||||||
|
// the map.
|
||||||
|
// key = "ABC", value = "DEF:GHI"
|
||||||
|
items[key] = value
|
||||||
|
} else {
|
||||||
|
// If either key or value is empty, we were not able to form proper key-value pair.
|
||||||
|
// So append the original line into an "EXTRA CONTENT" map entry
|
||||||
|
items.putOrAppend(EXTRA_CONTENT, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findUsername(): String? {
|
||||||
|
extraContent.splitToSequence("\n").forEach { line ->
|
||||||
|
for (prefix in USERNAME_FIELDS) {
|
||||||
|
if (line.startsWith(prefix, ignoreCase = true)) return line.substring(prefix.length).trimStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findAndStripPassword(passContent: List<String>): Pair<String, List<String>> {
|
||||||
|
if (UriTotpFinder.TOTP_FIELDS.any { passContent[0].startsWith(it) }) return Pair("", passContent)
|
||||||
|
for (line in passContent) {
|
||||||
|
for (prefix in PASSWORD_FIELDS) {
|
||||||
|
if (line.startsWith(prefix, ignoreCase = true)) {
|
||||||
|
return Pair(line.substring(prefix.length).trimStart(), passContent.minus(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Pair(passContent[0], passContent.minus(passContent[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findTotpSecret(decryptedContent: String): String? {
|
||||||
|
return totpFinder.findSecret(decryptedContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findOtpDigits(decryptedContent: String): String {
|
||||||
|
return totpFinder.findDigits(decryptedContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findTotpPeriod(decryptedContent: String): Long {
|
||||||
|
return totpFinder.findPeriod(decryptedContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findTotpAlgorithm(decryptedContent: String): String {
|
||||||
|
return totpFinder.findAlgorithm(decryptedContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val EXTRA_CONTENT = "Extra Content"
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
val digits: String
|
val USERNAME_FIELDS =
|
||||||
|
arrayOf(
|
||||||
|
"login:",
|
||||||
|
"username:",
|
||||||
|
"user:",
|
||||||
|
"account:",
|
||||||
|
"email:",
|
||||||
|
"name:",
|
||||||
|
"handle:",
|
||||||
|
"id:",
|
||||||
|
"identity:",
|
||||||
|
)
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||||
val totpSecret: String?
|
val PASSWORD_FIELDS =
|
||||||
val totpPeriod: Long
|
arrayOf(
|
||||||
|
"password:",
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
"secret:",
|
||||||
val totpAlgorithm: String
|
"pass:",
|
||||||
val extraContent: String
|
)
|
||||||
val extraContentWithoutAuthData: String
|
}
|
||||||
val extraContentMap: Map<String, String>
|
|
||||||
|
|
||||||
constructor(os: ByteArrayOutputStream) : this(os.toString(Charsets.UTF_8.name()), UriTotpFinder())
|
|
||||||
|
|
||||||
init {
|
|
||||||
val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
|
|
||||||
password = foundPassword
|
|
||||||
extraContent = passContent.joinToString("\n")
|
|
||||||
extraContentWithoutAuthData = generateExtraContentWithoutAuthData()
|
|
||||||
extraContentMap = generateExtraContentPairs()
|
|
||||||
username = findUsername()
|
|
||||||
digits = findOtpDigits(content)
|
|
||||||
totpSecret = findTotpSecret(content)
|
|
||||||
totpPeriod = findTotpPeriod(content)
|
|
||||||
totpAlgorithm = findTotpAlgorithm(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasExtraContent(): Boolean {
|
|
||||||
return extraContent.isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasExtraContentWithoutAuthData(): Boolean {
|
|
||||||
return extraContentWithoutAuthData.isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasTotp(): Boolean {
|
|
||||||
return totpSecret != null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hasUsername(): Boolean {
|
|
||||||
return username != null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun calculateTotpCode(): String? {
|
|
||||||
if (totpSecret == null)
|
|
||||||
return null
|
|
||||||
return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateExtraContentWithoutAuthData(): String {
|
|
||||||
var foundUsername = false
|
|
||||||
return extraContent
|
|
||||||
.lineSequence()
|
|
||||||
.filter { line ->
|
|
||||||
return@filter when {
|
|
||||||
USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> {
|
|
||||||
foundUsername = true
|
|
||||||
false
|
|
||||||
}
|
|
||||||
line.startsWith("otpauth://", ignoreCase = true) ||
|
|
||||||
line.startsWith("totp:", ignoreCase = true) -> {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.joinToString(separator = "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateExtraContentPairs(): Map<String, String> {
|
|
||||||
fun MutableMap<String, String>.putOrAppend(key: String, value: String) {
|
|
||||||
if (value.isEmpty()) return
|
|
||||||
val existing = this[key]
|
|
||||||
this[key] = if (existing == null) {
|
|
||||||
value
|
|
||||||
} else {
|
|
||||||
"$existing\n$value"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val items = mutableMapOf<String, String>()
|
|
||||||
// Take extraContentWithoutAuthData and onEach line perform the following tasks
|
|
||||||
extraContentWithoutAuthData.lines().forEach { line ->
|
|
||||||
// Split the line on ':' and save all the parts into an array
|
|
||||||
// "ABC : DEF:GHI" --> ["ABC", "DEF", "GHI"]
|
|
||||||
val splitArray = line.split(":")
|
|
||||||
// Take the first element of the array. This will be the key for the key-value pair.
|
|
||||||
// ["ABC ", " DEF", "GHI"] -> key = "ABC"
|
|
||||||
val key = splitArray.first().trimEnd()
|
|
||||||
// Remove the first element from the array and join the rest of the string again with ':' as separator.
|
|
||||||
// ["ABC ", " DEF", "GHI"] -> value = "DEF:GHI"
|
|
||||||
val value = splitArray.drop(1).joinToString(":").trimStart()
|
|
||||||
|
|
||||||
if (key.isNotEmpty() && value.isNotEmpty()) {
|
|
||||||
// If both key and value are not empty, we can form a pair with this so add it to the map.
|
|
||||||
// key = "ABC", value = "DEF:GHI"
|
|
||||||
items[key] = value
|
|
||||||
} else {
|
|
||||||
// If either key or value is empty, we were not able to form proper key-value pair.
|
|
||||||
// So append the original line into an "EXTRA CONTENT" map entry
|
|
||||||
items.putOrAppend(EXTRA_CONTENT, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findUsername(): String? {
|
|
||||||
extraContent.splitToSequence("\n").forEach { line ->
|
|
||||||
for (prefix in USERNAME_FIELDS) {
|
|
||||||
if (line.startsWith(prefix, ignoreCase = true))
|
|
||||||
return line.substring(prefix.length).trimStart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findAndStripPassword(passContent: List<String>): Pair<String, List<String>> {
|
|
||||||
if (UriTotpFinder.TOTP_FIELDS.any { passContent[0].startsWith(it) }) return Pair("", passContent)
|
|
||||||
for (line in passContent) {
|
|
||||||
for (prefix in PASSWORD_FIELDS) {
|
|
||||||
if (line.startsWith(prefix, ignoreCase = true)) {
|
|
||||||
return Pair(line.substring(prefix.length).trimStart(), passContent.minus(line))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Pair(passContent[0], passContent.minus(passContent[0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findTotpSecret(decryptedContent: String): String? {
|
|
||||||
return totpFinder.findSecret(decryptedContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findOtpDigits(decryptedContent: String): String {
|
|
||||||
return totpFinder.findDigits(decryptedContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findTotpPeriod(decryptedContent: String): Long {
|
|
||||||
return totpFinder.findPeriod(decryptedContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findTotpAlgorithm(decryptedContent: String): String {
|
|
||||||
return totpFinder.findAlgorithm(decryptedContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val EXTRA_CONTENT = "Extra Content"
|
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
|
||||||
val USERNAME_FIELDS = arrayOf(
|
|
||||||
"login:",
|
|
||||||
"username:",
|
|
||||||
"user:",
|
|
||||||
"account:",
|
|
||||||
"email:",
|
|
||||||
"name:",
|
|
||||||
"handle:",
|
|
||||||
"id:",
|
|
||||||
"identity:",
|
|
||||||
)
|
|
||||||
|
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
|
||||||
val PASSWORD_FIELDS = arrayOf(
|
|
||||||
"password:",
|
|
||||||
"secret:",
|
|
||||||
"pass:",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,79 +8,56 @@ import dev.msfjarvis.aps.ui.crypto.BasePgpActivity
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
data class PasswordItem(
|
data class PasswordItem(
|
||||||
val name: String,
|
val name: String,
|
||||||
val parent: PasswordItem? = null,
|
val parent: PasswordItem? = null,
|
||||||
val type: Char,
|
val type: Char,
|
||||||
val file: File,
|
val file: File,
|
||||||
val rootDir: File
|
val rootDir: File
|
||||||
) : Comparable<PasswordItem> {
|
) : Comparable<PasswordItem> {
|
||||||
|
|
||||||
val fullPathToParent = file.absolutePath
|
val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "")
|
||||||
.replace(rootDir.absolutePath, "")
|
|
||||||
.replace(file.name, "")
|
|
||||||
|
|
||||||
val longName = BasePgpActivity.getLongName(
|
val longName = BasePgpActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString())
|
||||||
fullPathToParent,
|
|
||||||
rootDir.absolutePath,
|
|
||||||
toString())
|
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
return (other is PasswordItem) && (other.file == file)
|
return (other is PasswordItem) && (other.file == file)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun compareTo(other: PasswordItem): Int {
|
||||||
|
return (type + name).compareTo(other.type + other.name, ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return name.replace("\\.gpg$".toRegex(), "")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val TYPE_CATEGORY = 'c'
|
||||||
|
const val TYPE_PASSWORD = 'p'
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
|
||||||
|
return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun compareTo(other: PasswordItem): Int {
|
@JvmStatic
|
||||||
return (type + name).compareTo(other.type + other.name, ignoreCase = true)
|
fun newCategory(name: String, file: File, rootDir: File): PasswordItem {
|
||||||
|
return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toString(): String {
|
@JvmStatic
|
||||||
return name.replace("\\.gpg$".toRegex(), "")
|
fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
|
||||||
|
return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
@JvmStatic
|
||||||
return 0
|
fun newPassword(name: String, file: File, rootDir: File): PasswordItem {
|
||||||
}
|
return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val TYPE_CATEGORY = 'c'
|
|
||||||
const val TYPE_PASSWORD = 'p'
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun newCategory(
|
|
||||||
name: String,
|
|
||||||
file: File,
|
|
||||||
parent: PasswordItem,
|
|
||||||
rootDir: File
|
|
||||||
): PasswordItem {
|
|
||||||
return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun newCategory(
|
|
||||||
name: String,
|
|
||||||
file: File,
|
|
||||||
rootDir: File
|
|
||||||
): PasswordItem {
|
|
||||||
return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun newPassword(
|
|
||||||
name: String,
|
|
||||||
file: File,
|
|
||||||
parent: PasswordItem,
|
|
||||||
rootDir: File
|
|
||||||
): PasswordItem {
|
|
||||||
return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun newPassword(
|
|
||||||
name: String,
|
|
||||||
file: File,
|
|
||||||
rootDir: File
|
|
||||||
): PasswordItem {
|
|
||||||
return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,213 +31,211 @@ import org.eclipse.jgit.util.FS_POSIX_Java6
|
||||||
|
|
||||||
object PasswordRepository {
|
object PasswordRepository {
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private class FS_POSIX_Java6_with_optional_symlinks : FS_POSIX_Java6() {
|
private class FS_POSIX_Java6_with_optional_symlinks : FS_POSIX_Java6() {
|
||||||
|
|
||||||
override fun supportsSymlinks() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
override fun supportsSymlinks() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||||
|
|
||||||
override fun isSymLink(file: File) = Files.isSymbolicLink(file.toPath())
|
override fun isSymLink(file: File) = Files.isSymbolicLink(file.toPath())
|
||||||
|
|
||||||
override fun readSymLink(file: File) = Files.readSymbolicLink(file.toPath()).toString()
|
override fun readSymLink(file: File) = Files.readSymbolicLink(file.toPath()).toString()
|
||||||
|
|
||||||
override fun createSymLink(source: File, target: String) {
|
override fun createSymLink(source: File, target: String) {
|
||||||
val sourcePath = source.toPath()
|
val sourcePath = source.toPath()
|
||||||
if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS))
|
if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) Files.delete(sourcePath)
|
||||||
Files.delete(sourcePath)
|
Files.createSymbolicLink(sourcePath, File(target).toPath())
|
||||||
Files.createSymbolicLink(sourcePath, File(target).toPath())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private class Java7FSFactory : FS.FSFactory() {
|
private class Java7FSFactory : FS.FSFactory() {
|
||||||
|
|
||||||
override fun detect(cygwinUsed: Boolean?): FS {
|
override fun detect(cygwinUsed: Boolean?): FS {
|
||||||
return FS_POSIX_Java6_with_optional_symlinks()
|
return FS_POSIX_Java6_with_optional_symlinks()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var repository: Repository? = null
|
private var repository: Repository? = null
|
||||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { Application.instance.sharedPrefs }
|
private val settings by lazy(LazyThreadSafetyMode.NONE) { Application.instance.sharedPrefs }
|
||||||
private val filesDir
|
private val filesDir
|
||||||
get() = Application.instance.filesDir
|
get() = Application.instance.filesDir
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the git repository
|
* Returns the git repository
|
||||||
*
|
*
|
||||||
* @param localDir needed only on the creation
|
* @param localDir needed only on the creation
|
||||||
* @return the git repository
|
* @return the git repository
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getRepository(localDir: File?): Repository? {
|
fun getRepository(localDir: File?): Repository? {
|
||||||
if (repository == null && localDir != null) {
|
if (repository == null && localDir != null) {
|
||||||
val builder = FileRepositoryBuilder()
|
val builder = FileRepositoryBuilder()
|
||||||
repository = runCatching {
|
repository =
|
||||||
builder.run {
|
runCatching {
|
||||||
gitDir = localDir
|
builder
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
.run {
|
||||||
fs = Java7FSFactory().detect(null)
|
gitDir = localDir
|
||||||
}
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
readEnvironment()
|
fs = Java7FSFactory().detect(null)
|
||||||
}.build()
|
}
|
||||||
}.getOrElse { e ->
|
readEnvironment()
|
||||||
e.printStackTrace()
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
return repository
|
.getOrElse { e ->
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return repository
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
val isInitialized: Boolean
|
val isInitialized: Boolean
|
||||||
get() = repository != null
|
get() = repository != null
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun isGitRepo(): Boolean {
|
fun isGitRepo(): Boolean {
|
||||||
if (repository != null) {
|
if (repository != null) {
|
||||||
return repository!!.objectDatabase.exists()
|
return repository!!.objectDatabase.exists()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun createRepository(localDir: File) {
|
||||||
|
localDir.delete()
|
||||||
|
|
||||||
|
Git.init().setDirectory(localDir).call()
|
||||||
|
getRepository(localDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO add multiple remotes support for pull/push
|
||||||
|
@JvmStatic
|
||||||
|
fun addRemote(name: String, url: String, replace: Boolean = false) {
|
||||||
|
val storedConfig = repository!!.config
|
||||||
|
val remotes = storedConfig.getSubsections("remote")
|
||||||
|
|
||||||
|
if (!remotes.contains(name)) {
|
||||||
|
runCatching {
|
||||||
|
val uri = URIish(url)
|
||||||
|
val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*")
|
||||||
|
|
||||||
|
val remoteConfig = RemoteConfig(storedConfig, name)
|
||||||
|
remoteConfig.addFetchRefSpec(refSpec)
|
||||||
|
remoteConfig.addPushRefSpec(refSpec)
|
||||||
|
remoteConfig.addURI(uri)
|
||||||
|
remoteConfig.addPushURI(uri)
|
||||||
|
|
||||||
|
remoteConfig.update(storedConfig)
|
||||||
|
|
||||||
|
storedConfig.save()
|
||||||
|
}
|
||||||
|
.onFailure { e -> e.printStackTrace() }
|
||||||
|
} else if (replace) {
|
||||||
|
runCatching {
|
||||||
|
val uri = URIish(url)
|
||||||
|
|
||||||
|
val remoteConfig = RemoteConfig(storedConfig, name)
|
||||||
|
// remove the first and eventually the only uri
|
||||||
|
if (remoteConfig.urIs.size > 0) {
|
||||||
|
remoteConfig.removeURI(remoteConfig.urIs[0])
|
||||||
}
|
}
|
||||||
return false
|
if (remoteConfig.pushURIs.size > 0) {
|
||||||
}
|
remoteConfig.removePushURI(remoteConfig.pushURIs[0])
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
@Throws(Exception::class)
|
|
||||||
fun createRepository(localDir: File) {
|
|
||||||
localDir.delete()
|
|
||||||
|
|
||||||
Git.init().setDirectory(localDir).call()
|
|
||||||
getRepository(localDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO add multiple remotes support for pull/push
|
|
||||||
@JvmStatic
|
|
||||||
fun addRemote(name: String, url: String, replace: Boolean = false) {
|
|
||||||
val storedConfig = repository!!.config
|
|
||||||
val remotes = storedConfig.getSubsections("remote")
|
|
||||||
|
|
||||||
if (!remotes.contains(name)) {
|
|
||||||
runCatching {
|
|
||||||
val uri = URIish(url)
|
|
||||||
val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*")
|
|
||||||
|
|
||||||
val remoteConfig = RemoteConfig(storedConfig, name)
|
|
||||||
remoteConfig.addFetchRefSpec(refSpec)
|
|
||||||
remoteConfig.addPushRefSpec(refSpec)
|
|
||||||
remoteConfig.addURI(uri)
|
|
||||||
remoteConfig.addPushURI(uri)
|
|
||||||
|
|
||||||
remoteConfig.update(storedConfig)
|
|
||||||
|
|
||||||
storedConfig.save()
|
|
||||||
}.onFailure { e ->
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
} else if (replace) {
|
|
||||||
runCatching {
|
|
||||||
val uri = URIish(url)
|
|
||||||
|
|
||||||
val remoteConfig = RemoteConfig(storedConfig, name)
|
|
||||||
// remove the first and eventually the only uri
|
|
||||||
if (remoteConfig.urIs.size > 0) {
|
|
||||||
remoteConfig.removeURI(remoteConfig.urIs[0])
|
|
||||||
}
|
|
||||||
if (remoteConfig.pushURIs.size > 0) {
|
|
||||||
remoteConfig.removePushURI(remoteConfig.pushURIs[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteConfig.addURI(uri)
|
|
||||||
remoteConfig.addPushURI(uri)
|
|
||||||
|
|
||||||
remoteConfig.update(storedConfig)
|
|
||||||
|
|
||||||
storedConfig.save()
|
|
||||||
}.onFailure { e ->
|
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remoteConfig.addURI(uri)
|
||||||
|
remoteConfig.addPushURI(uri)
|
||||||
|
|
||||||
|
remoteConfig.update(storedConfig)
|
||||||
|
|
||||||
|
storedConfig.save()
|
||||||
|
}
|
||||||
|
.onFailure { e -> e.printStackTrace() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun closeRepository() {
|
||||||
|
if (repository != null) repository!!.close()
|
||||||
|
repository = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getRepositoryDirectory(): File {
|
||||||
|
return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
|
||||||
|
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||||
|
if (externalRepo != null) File(externalRepo) else File(filesDir.toString(), "/store")
|
||||||
|
} else {
|
||||||
|
File(filesDir.toString(), "/store")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun initialize(): Repository? {
|
||||||
|
val dir = getRepositoryDirectory()
|
||||||
|
// uninitialize the repo if the dir does not exist or is absolutely empty
|
||||||
|
settings.edit {
|
||||||
|
if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) {
|
||||||
|
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
|
||||||
|
} else {
|
||||||
|
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
// create the repository static variable in PasswordRepository
|
||||||
fun closeRepository() {
|
return getRepository(File(dir.absolutePath + "/.git"))
|
||||||
if (repository != null) repository!!.close()
|
}
|
||||||
repository = null
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
/**
|
||||||
fun getRepositoryDirectory(): File {
|
* Gets the .gpg files in a directory
|
||||||
return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
|
*
|
||||||
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
* @param path the directory path
|
||||||
if (externalRepo != null)
|
* @return the list of gpg files in that directory
|
||||||
File(externalRepo)
|
*/
|
||||||
else
|
@JvmStatic
|
||||||
File(filesDir.toString(), "/store")
|
fun getFilesList(path: File?): ArrayList<File> {
|
||||||
|
if (path == null || !path.exists()) return ArrayList()
|
||||||
|
|
||||||
|
val directories = (path.listFiles(FileFilter { pathname -> pathname.isDirectory }) ?: emptyArray()).toList()
|
||||||
|
val files = (path.listFiles(FileFilter { pathname -> pathname.extension == "gpg" }) ?: emptyArray()).toList()
|
||||||
|
|
||||||
|
val items = ArrayList<File>()
|
||||||
|
items.addAll(directories)
|
||||||
|
items.addAll(files)
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the passwords (PasswordItem) in a directory
|
||||||
|
*
|
||||||
|
* @param path the directory path
|
||||||
|
* @return a list of password items
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getPasswords(path: File, rootDir: File, sortOrder: PasswordSortOrder): ArrayList<PasswordItem> {
|
||||||
|
// We need to recover the passwords then parse the files
|
||||||
|
val passList = getFilesList(path).also { it.sortBy { f -> f.name } }
|
||||||
|
val passwordList = ArrayList<PasswordItem>()
|
||||||
|
val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
|
||||||
|
|
||||||
|
if (passList.size == 0) return passwordList
|
||||||
|
if (!showHidden) {
|
||||||
|
passList.filter { !it.isHidden }.toCollection(passList.apply { clear() })
|
||||||
|
}
|
||||||
|
passList.forEach { file ->
|
||||||
|
passwordList.add(
|
||||||
|
if (file.isFile) {
|
||||||
|
PasswordItem.newPassword(file.name, file, rootDir)
|
||||||
} else {
|
} else {
|
||||||
File(filesDir.toString(), "/store")
|
PasswordItem.newCategory(file.name, file, rootDir)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
passwordList.sortWith(sortOrder.comparator)
|
||||||
@JvmStatic
|
return passwordList
|
||||||
fun initialize(): Repository? {
|
}
|
||||||
val dir = getRepositoryDirectory()
|
|
||||||
// uninitialize the repo if the dir does not exist or is absolutely empty
|
|
||||||
settings.edit {
|
|
||||||
if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) {
|
|
||||||
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
|
|
||||||
} else {
|
|
||||||
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// create the repository static variable in PasswordRepository
|
|
||||||
return getRepository(File(dir.absolutePath + "/.git"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the .gpg files in a directory
|
|
||||||
*
|
|
||||||
* @param path the directory path
|
|
||||||
* @return the list of gpg files in that directory
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun getFilesList(path: File?): ArrayList<File> {
|
|
||||||
if (path == null || !path.exists()) return ArrayList()
|
|
||||||
|
|
||||||
val directories = (path.listFiles(FileFilter { pathname -> pathname.isDirectory })
|
|
||||||
?: emptyArray()).toList()
|
|
||||||
val files = (path.listFiles(FileFilter { pathname -> pathname.extension == "gpg" })
|
|
||||||
?: emptyArray()).toList()
|
|
||||||
|
|
||||||
val items = ArrayList<File>()
|
|
||||||
items.addAll(directories)
|
|
||||||
items.addAll(files)
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the passwords (PasswordItem) in a directory
|
|
||||||
*
|
|
||||||
* @param path the directory path
|
|
||||||
* @return a list of password items
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun getPasswords(path: File, rootDir: File, sortOrder: PasswordSortOrder): ArrayList<PasswordItem> {
|
|
||||||
// We need to recover the passwords then parse the files
|
|
||||||
val passList = getFilesList(path).also { it.sortBy { f -> f.name } }
|
|
||||||
val passwordList = ArrayList<PasswordItem>()
|
|
||||||
val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
|
|
||||||
|
|
||||||
if (passList.size == 0) return passwordList
|
|
||||||
if (!showHidden) {
|
|
||||||
passList.filter { !it.isHidden }.toCollection(passList.apply { clear() })
|
|
||||||
}
|
|
||||||
passList.forEach { file ->
|
|
||||||
passwordList.add(if (file.isFile) {
|
|
||||||
PasswordItem.newPassword(file.name, file, rootDir)
|
|
||||||
} else {
|
|
||||||
PasswordItem.newCategory(file.name, file, rootDir)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
passwordList.sortWith(sortOrder.comparator)
|
|
||||||
return passwordList
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,74 +17,74 @@ import dev.msfjarvis.aps.data.password.FieldItem
|
||||||
import dev.msfjarvis.aps.databinding.ItemFieldBinding
|
import dev.msfjarvis.aps.databinding.ItemFieldBinding
|
||||||
|
|
||||||
class FieldItemAdapter(
|
class FieldItemAdapter(
|
||||||
private var fieldItemList: List<FieldItem>,
|
private var fieldItemList: List<FieldItem>,
|
||||||
private val showPassword: Boolean,
|
private val showPassword: Boolean,
|
||||||
private val copyTextToClipBoard: (text: String?) -> Unit,
|
private val copyTextToClipBoard: (text: String?) -> Unit,
|
||||||
) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() {
|
) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() {
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
|
||||||
val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
return FieldItemViewHolder(binding.root, binding)
|
return FieldItemViewHolder(binding.root, binding)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
|
||||||
holder.bind(fieldItemList[position], showPassword, copyTextToClipBoard)
|
holder.bind(fieldItemList[position], showPassword, copyTextToClipBoard)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount(): Int {
|
override fun getItemCount(): Int {
|
||||||
return fieldItemList.size
|
return fieldItemList.size
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateOTPCode(code: String) {
|
fun updateOTPCode(code: String) {
|
||||||
var otpItemPosition = -1;
|
var otpItemPosition = -1
|
||||||
fieldItemList = fieldItemList.mapIndexed { position, item ->
|
fieldItemList =
|
||||||
if (item.key.equals(FieldItem.ItemType.OTP.type, true)) {
|
fieldItemList.mapIndexed { position, item ->
|
||||||
otpItemPosition = position
|
if (item.key.equals(FieldItem.ItemType.OTP.type, true)) {
|
||||||
return@mapIndexed FieldItem.createOtpField(code)
|
otpItemPosition = position
|
||||||
}
|
return@mapIndexed FieldItem.createOtpField(code)
|
||||||
|
|
||||||
return@mapIndexed item
|
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyItemChanged(otpItemPosition)
|
return@mapIndexed item
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateItems(itemList: List<FieldItem>) {
|
notifyItemChanged(otpItemPosition)
|
||||||
fieldItemList = itemList
|
}
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) :
|
fun updateItems(itemList: List<FieldItem>) {
|
||||||
RecyclerView.ViewHolder(itemView) {
|
fieldItemList = itemList
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) {
|
class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : RecyclerView.ViewHolder(itemView) {
|
||||||
with(binding) {
|
|
||||||
itemText.hint = fieldItem.key
|
|
||||||
itemTextContainer.hint = fieldItem.key
|
|
||||||
itemText.setText(fieldItem.value)
|
|
||||||
|
|
||||||
when (fieldItem.action) {
|
fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) {
|
||||||
FieldItem.ActionType.COPY -> {
|
with(binding) {
|
||||||
itemTextContainer.apply {
|
itemText.hint = fieldItem.key
|
||||||
endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
|
itemTextContainer.hint = fieldItem.key
|
||||||
endIconMode = TextInputLayout.END_ICON_CUSTOM
|
itemText.setText(fieldItem.value)
|
||||||
setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
|
||||||
}
|
when (fieldItem.action) {
|
||||||
}
|
FieldItem.ActionType.COPY -> {
|
||||||
FieldItem.ActionType.HIDE -> {
|
itemTextContainer.apply {
|
||||||
itemTextContainer.apply {
|
endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
|
||||||
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
|
endIconMode = TextInputLayout.END_ICON_CUSTOM
|
||||||
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
||||||
}
|
|
||||||
itemText.apply {
|
|
||||||
if (!showPassword) {
|
|
||||||
transformationMethod = PasswordTransformationMethod.getInstance()
|
|
||||||
}
|
|
||||||
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
FieldItem.ActionType.HIDE -> {
|
||||||
|
itemTextContainer.apply {
|
||||||
|
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
|
||||||
|
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
||||||
|
}
|
||||||
|
itemText.apply {
|
||||||
|
if (!showPassword) {
|
||||||
|
transformationMethod = PasswordTransformationMethod.getInstance()
|
||||||
|
}
|
||||||
|
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,65 +19,66 @@ import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter
|
||||||
import dev.msfjarvis.aps.util.viewmodel.stableId
|
import dev.msfjarvis.aps.util.viewmodel.stableId
|
||||||
|
|
||||||
open class PasswordItemRecyclerAdapter :
|
open class PasswordItemRecyclerAdapter :
|
||||||
SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>(
|
SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>(
|
||||||
R.layout.password_row_layout,
|
R.layout.password_row_layout,
|
||||||
::PasswordItemViewHolder,
|
::PasswordItemViewHolder,
|
||||||
PasswordItemViewHolder::bind
|
PasswordItemViewHolder::bind
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun makeSelectable(recyclerView: RecyclerView) {
|
fun makeSelectable(recyclerView: RecyclerView) {
|
||||||
makeSelectable(recyclerView, ::PasswordItemDetailsLookup)
|
makeSelectable(recyclerView, ::PasswordItemDetailsLookup)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemClicked(listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit): PasswordItemRecyclerAdapter {
|
override fun onItemClicked(
|
||||||
return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
|
listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit
|
||||||
}
|
): PasswordItemRecyclerAdapter {
|
||||||
|
return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
|
||||||
|
}
|
||||||
|
|
||||||
override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter {
|
override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter {
|
||||||
return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter
|
return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
|
||||||
private val name: AppCompatTextView = itemView.findViewById(R.id.label)
|
private val name: AppCompatTextView = itemView.findViewById(R.id.label)
|
||||||
private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count)
|
private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count)
|
||||||
private val folderIndicator: AppCompatImageView =
|
private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator)
|
||||||
itemView.findViewById(R.id.folder_indicator)
|
lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String>
|
||||||
lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String>
|
|
||||||
|
|
||||||
fun bind(item: PasswordItem) {
|
fun bind(item: PasswordItem) {
|
||||||
val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "")
|
val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "")
|
||||||
val source = if (parentPath.isNotEmpty()) {
|
val source =
|
||||||
"$parentPath\n$item"
|
if (parentPath.isNotEmpty()) {
|
||||||
} else {
|
"$parentPath\n$item"
|
||||||
"$item"
|
} else {
|
||||||
}
|
"$item"
|
||||||
val spannable = SpannableString(source)
|
}
|
||||||
spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0)
|
val spannable = SpannableString(source)
|
||||||
name.text = spannable
|
spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0)
|
||||||
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
name.text = spannable
|
||||||
folderIndicator.visibility = View.VISIBLE
|
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
||||||
val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size
|
folderIndicator.visibility = View.VISIBLE
|
||||||
?: 0
|
val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0
|
||||||
childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
|
childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
|
||||||
childCount.text = "$count"
|
childCount.text = "$count"
|
||||||
} else {
|
} else {
|
||||||
childCount.visibility = View.GONE
|
childCount.visibility = View.GONE
|
||||||
folderIndicator.visibility = View.GONE
|
folderIndicator.visibility = View.GONE
|
||||||
}
|
}
|
||||||
itemDetails = object : ItemDetailsLookup.ItemDetails<String>() {
|
itemDetails =
|
||||||
override fun getPosition() = absoluteAdapterPosition
|
object : ItemDetailsLookup.ItemDetails<String>() {
|
||||||
override fun getSelectionKey() = item.stableId
|
override fun getPosition() = absoluteAdapterPosition
|
||||||
}
|
override fun getSelectionKey() = item.stableId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) :
|
class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup<String>() {
|
||||||
ItemDetailsLookup<String>() {
|
|
||||||
|
|
||||||
override fun getItemDetails(event: MotionEvent): ItemDetails<String>? {
|
override fun getItemDetails(event: MotionEvent): ItemDetails<String>? {
|
||||||
val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null
|
val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null
|
||||||
return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails
|
return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,195 +51,184 @@ import org.openintents.openpgp.OpenPgpError
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
|
class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH"
|
private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH"
|
||||||
private const val EXTRA_SEARCH_ACTION =
|
private const val EXTRA_SEARCH_ACTION = "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION"
|
||||||
"dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION"
|
|
||||||
|
|
||||||
private var decryptFileRequestCode = 1
|
private var decryptFileRequestCode = 1
|
||||||
|
|
||||||
fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
|
fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
|
||||||
return Intent(context, AutofillDecryptActivity::class.java).apply {
|
return Intent(context, AutofillDecryptActivity::class.java).apply {
|
||||||
putExtras(forwardedExtras)
|
putExtras(forwardedExtras)
|
||||||
putExtra(EXTRA_SEARCH_ACTION, true)
|
putExtra(EXTRA_SEARCH_ACTION, true)
|
||||||
putExtra(EXTRA_FILE_PATH, file.absolutePath)
|
putExtra(EXTRA_FILE_PATH, file.absolutePath)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
|
|
||||||
val intent = Intent(context, AutofillDecryptActivity::class.java).apply {
|
|
||||||
putExtra(EXTRA_SEARCH_ACTION, false)
|
|
||||||
putExtra(EXTRA_FILE_PATH, file.absolutePath)
|
|
||||||
}
|
|
||||||
return PendingIntent.getActivity(
|
|
||||||
context,
|
|
||||||
decryptFileRequestCode++,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_CANCEL_CURRENT
|
|
||||||
).intentSender
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val decryptInteractionRequiredAction = registerForActivityResult(StartIntentSenderForResult()) { result ->
|
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
|
||||||
if (continueAfterUserInteraction != null) {
|
val intent =
|
||||||
val data = result.data
|
Intent(context, AutofillDecryptActivity::class.java).apply {
|
||||||
if (result.resultCode == RESULT_OK && data != null) {
|
putExtra(EXTRA_SEARCH_ACTION, false)
|
||||||
continueAfterUserInteraction?.resume(data)
|
putExtra(EXTRA_FILE_PATH, file.absolutePath)
|
||||||
} else {
|
|
||||||
continueAfterUserInteraction?.resumeWithException(Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction"))
|
|
||||||
}
|
|
||||||
continueAfterUserInteraction = null
|
|
||||||
}
|
}
|
||||||
|
return PendingIntent.getActivity(context, decryptFileRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
|
||||||
|
.intentSender
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val decryptInteractionRequiredAction =
|
||||||
|
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||||
|
if (continueAfterUserInteraction != null) {
|
||||||
|
val data = result.data
|
||||||
|
if (result.resultCode == RESULT_OK && data != null) {
|
||||||
|
continueAfterUserInteraction?.resume(data)
|
||||||
|
} else {
|
||||||
|
continueAfterUserInteraction?.resumeWithException(
|
||||||
|
Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
continueAfterUserInteraction = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var continueAfterUserInteraction: Continuation<Intent>? = null
|
private var continueAfterUserInteraction: Continuation<Intent>? = null
|
||||||
private lateinit var directoryStructure: DirectoryStructure
|
private lateinit var directoryStructure: DirectoryStructure
|
||||||
|
|
||||||
override val coroutineContext
|
override val coroutineContext
|
||||||
get() = Dispatchers.IO + SupervisorJob()
|
get() = Dispatchers.IO + SupervisorJob()
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run {
|
val filePath =
|
||||||
e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
|
intent?.getStringExtra(EXTRA_FILE_PATH)
|
||||||
finish()
|
?: run {
|
||||||
return
|
e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
|
||||||
|
finish()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
|
val clientState =
|
||||||
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
|
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
|
||||||
finish()
|
?: run {
|
||||||
return
|
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
|
||||||
|
finish()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
|
val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
|
||||||
val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
|
val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
|
||||||
directoryStructure = AutofillPreferences.directoryStructure(this)
|
directoryStructure = AutofillPreferences.directoryStructure(this)
|
||||||
d { action.toString() }
|
d { action.toString() }
|
||||||
launch {
|
launch {
|
||||||
val credentials = decryptCredential(File(filePath))
|
val credentials = decryptCredential(File(filePath))
|
||||||
if (credentials == null) {
|
if (credentials == null) {
|
||||||
setResult(RESULT_CANCELED)
|
setResult(RESULT_CANCELED)
|
||||||
} else {
|
} else {
|
||||||
val fillInDataset =
|
val fillInDataset =
|
||||||
AutofillResponseBuilder.makeFillInDataset(
|
AutofillResponseBuilder.makeFillInDataset(this@AutofillDecryptActivity, credentials, clientState, action)
|
||||||
this@AutofillDecryptActivity,
|
withContext(Dispatchers.Main) {
|
||||||
credentials,
|
setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) })
|
||||||
clientState,
|
}
|
||||||
action
|
}
|
||||||
)
|
withContext(Dispatchers.Main) { finish() }
|
||||||
withContext(Dispatchers.Main) {
|
}
|
||||||
setResult(RESULT_OK, Intent().apply {
|
}
|
||||||
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
|
|
||||||
})
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
coroutineContext.cancelChildren()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun executeOpenPgpApi(data: Intent, input: InputStream, output: OutputStream): Intent? {
|
||||||
|
var openPgpServiceConnection: OpenPgpServiceConnection? = null
|
||||||
|
val openPgpService =
|
||||||
|
suspendCoroutine<IOpenPgpService2> { cont ->
|
||||||
|
openPgpServiceConnection =
|
||||||
|
OpenPgpServiceConnection(
|
||||||
|
this,
|
||||||
|
OPENPGP_PROVIDER,
|
||||||
|
object : OpenPgpServiceConnection.OnBound {
|
||||||
|
override fun onBound(service: IOpenPgpService2) {
|
||||||
|
cont.resume(service)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onError(e: Exception) {
|
||||||
super.onDestroy()
|
cont.resumeWithException(e)
|
||||||
coroutineContext.cancelChildren()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun executeOpenPgpApi(
|
|
||||||
data: Intent,
|
|
||||||
input: InputStream,
|
|
||||||
output: OutputStream
|
|
||||||
): Intent? {
|
|
||||||
var openPgpServiceConnection: OpenPgpServiceConnection? = null
|
|
||||||
val openPgpService = suspendCoroutine<IOpenPgpService2> { cont ->
|
|
||||||
openPgpServiceConnection = OpenPgpServiceConnection(
|
|
||||||
this,
|
|
||||||
OPENPGP_PROVIDER,
|
|
||||||
object : OpenPgpServiceConnection.OnBound {
|
|
||||||
override fun onBound(service: IOpenPgpService2) {
|
|
||||||
cont.resume(service)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(e: Exception) {
|
|
||||||
cont.resumeWithException(e)
|
|
||||||
}
|
|
||||||
}).also { it.bindToService() }
|
|
||||||
}
|
|
||||||
return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
|
|
||||||
openPgpServiceConnection?.unbindFromService()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun decryptCredential(
|
|
||||||
file: File,
|
|
||||||
resumeIntent: Intent? = null
|
|
||||||
): Credentials? {
|
|
||||||
val command = resumeIntent ?: Intent().apply {
|
|
||||||
action = OpenPgpApi.ACTION_DECRYPT_VERIFY
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
file.inputStream()
|
|
||||||
}.onFailure { e ->
|
|
||||||
e(e) { "File to decrypt not found" }
|
|
||||||
return null
|
|
||||||
}.onSuccess { encryptedInput ->
|
|
||||||
val decryptedOutput = ByteArrayOutputStream()
|
|
||||||
runCatching {
|
|
||||||
executeOpenPgpApi(command, encryptedInput, decryptedOutput)
|
|
||||||
}.onFailure { e ->
|
|
||||||
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" }
|
|
||||||
return null
|
|
||||||
}.onSuccess { result ->
|
|
||||||
return when (val resultCode =
|
|
||||||
result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
|
||||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
|
||||||
runCatching {
|
|
||||||
val entry = withContext(Dispatchers.IO) {
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
(PasswordEntry(decryptedOutput))
|
|
||||||
}
|
|
||||||
AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
|
|
||||||
}.getOrElse { e ->
|
|
||||||
e(e) { "Failed to parse password entry" }
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
|
||||||
val pendingIntent: PendingIntent =
|
|
||||||
result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!
|
|
||||||
runCatching {
|
|
||||||
val intentToResume = withContext(Dispatchers.Main) {
|
|
||||||
suspendCoroutine<Intent> { cont ->
|
|
||||||
continueAfterUserInteraction = cont
|
|
||||||
decryptInteractionRequiredAction.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
decryptCredential(file, intentToResume)
|
|
||||||
}.getOrElse { e ->
|
|
||||||
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" }
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OpenPgpApi.RESULT_CODE_ERROR -> {
|
|
||||||
val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR)
|
|
||||||
if (error != null) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(
|
|
||||||
applicationContext,
|
|
||||||
"Error from OpenKeyChain: ${error.message}",
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" }
|
|
||||||
}
|
|
||||||
null
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
e { "Unrecognized OpenPgpApi result: $resultCode" }
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
.also { it.bindToService() }
|
||||||
|
}
|
||||||
|
return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
|
||||||
|
openPgpServiceConnection?.unbindFromService()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun decryptCredential(file: File, resumeIntent: Intent? = null): Credentials? {
|
||||||
|
val command = resumeIntent ?: Intent().apply { action = OpenPgpApi.ACTION_DECRYPT_VERIFY }
|
||||||
|
runCatching { file.inputStream() }
|
||||||
|
.onFailure { e ->
|
||||||
|
e(e) { "File to decrypt not found" }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
.onSuccess { encryptedInput ->
|
||||||
|
val decryptedOutput = ByteArrayOutputStream()
|
||||||
|
runCatching { executeOpenPgpApi(command, encryptedInput, decryptedOutput) }
|
||||||
|
.onFailure { e ->
|
||||||
|
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
.onSuccess { result ->
|
||||||
|
return when (val resultCode = result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||||
|
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||||
|
runCatching {
|
||||||
|
val entry =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext") (PasswordEntry(decryptedOutput))
|
||||||
|
}
|
||||||
|
AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
|
||||||
|
}
|
||||||
|
.getOrElse { e ->
|
||||||
|
e(e) { "Failed to parse password entry" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||||
|
val pendingIntent: PendingIntent = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!
|
||||||
|
runCatching {
|
||||||
|
val intentToResume =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
suspendCoroutine<Intent> { cont ->
|
||||||
|
continueAfterUserInteraction = cont
|
||||||
|
decryptInteractionRequiredAction.launch(
|
||||||
|
IntentSenderRequest.Builder(pendingIntent.intentSender).build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
decryptCredential(file, intentToResume)
|
||||||
|
}
|
||||||
|
.getOrElse { e ->
|
||||||
|
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OpenPgpApi.RESULT_CODE_ERROR -> {
|
||||||
|
val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR)
|
||||||
|
if (error != null) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(applicationContext, "Error from OpenKeyChain: ${error.message}", Toast.LENGTH_LONG)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" }
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
e { "Unrecognized OpenPgpApi result: $resultCode" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,180 +41,164 @@ import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel
|
||||||
@TargetApi(Build.VERSION_CODES.O)
|
@TargetApi(Build.VERSION_CODES.O)
|
||||||
class AutofillFilterView : AppCompatActivity() {
|
class AutofillFilterView : AppCompatActivity() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val HEIGHT_PERCENTAGE = 0.9
|
private const val HEIGHT_PERCENTAGE = 0.9
|
||||||
private const val WIDTH_PERCENTAGE = 0.75
|
private const val WIDTH_PERCENTAGE = 0.75
|
||||||
|
|
||||||
private const val EXTRA_FORM_ORIGIN_WEB =
|
private const val EXTRA_FORM_ORIGIN_WEB = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB"
|
||||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB"
|
private const val EXTRA_FORM_ORIGIN_APP = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
|
||||||
private const val EXTRA_FORM_ORIGIN_APP =
|
private var matchAndDecryptFileRequestCode = 1
|
||||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
|
|
||||||
private var matchAndDecryptFileRequestCode = 1
|
|
||||||
|
|
||||||
fun makeMatchAndDecryptFileIntentSender(
|
fun makeMatchAndDecryptFileIntentSender(context: Context, formOrigin: FormOrigin): IntentSender {
|
||||||
context: Context,
|
val intent =
|
||||||
formOrigin: FormOrigin
|
Intent(context, AutofillFilterView::class.java).apply {
|
||||||
): IntentSender {
|
when (formOrigin) {
|
||||||
val intent = Intent(context, AutofillFilterView::class.java).apply {
|
is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier)
|
||||||
when (formOrigin) {
|
is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
|
||||||
is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier)
|
}
|
||||||
is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return PendingIntent.getActivity(
|
|
||||||
context,
|
|
||||||
matchAndDecryptFileRequestCode++,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_CANCEL_CURRENT
|
|
||||||
).intentSender
|
|
||||||
}
|
}
|
||||||
}
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
private lateinit var formOrigin: FormOrigin
|
matchAndDecryptFileRequestCode++,
|
||||||
private lateinit var directoryStructure: DirectoryStructure
|
intent,
|
||||||
private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate)
|
PendingIntent.FLAG_CANCEL_CURRENT
|
||||||
|
|
||||||
private val model: SearchableRepositoryViewModel by viewModels {
|
|
||||||
ViewModelProvider.AndroidViewModelFactory(application)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val decryptAction = registerForActivityResult(StartActivityForResult()) { result ->
|
|
||||||
if (result.resultCode == RESULT_OK) {
|
|
||||||
setResult(RESULT_OK, result.data)
|
|
||||||
}
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(binding.root)
|
|
||||||
setFinishOnTouchOutside(true)
|
|
||||||
|
|
||||||
val params = window.attributes
|
|
||||||
params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt()
|
|
||||||
params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
|
|
||||||
window.attributes = params
|
|
||||||
|
|
||||||
if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
|
|
||||||
e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
formOrigin = when {
|
|
||||||
intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> {
|
|
||||||
FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!)
|
|
||||||
}
|
|
||||||
intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> {
|
|
||||||
FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" }
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
directoryStructure = AutofillPreferences.directoryStructure(this)
|
|
||||||
|
|
||||||
supportActionBar?.hide()
|
|
||||||
bindUI()
|
|
||||||
updateSearch()
|
|
||||||
setResult(RESULT_CANCELED)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindUI() {
|
|
||||||
with(binding) {
|
|
||||||
rvPassword.apply {
|
|
||||||
adapter = SearchableRepositoryAdapter(
|
|
||||||
R.layout.oreo_autofill_filter_row,
|
|
||||||
::PasswordViewHolder
|
|
||||||
) { item ->
|
|
||||||
val file = item.file.relativeTo(item.rootDir)
|
|
||||||
val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
|
|
||||||
val identifier = directoryStructure.getIdentifierFor(file)
|
|
||||||
val accountPart = directoryStructure.getAccountPartFor(file)
|
|
||||||
check(identifier != null || accountPart != null) { "At least one of identifier and accountPart should always be non-null" }
|
|
||||||
title.text = if (identifier != null) {
|
|
||||||
buildSpannedString {
|
|
||||||
if (pathToIdentifier != null)
|
|
||||||
append("$pathToIdentifier/")
|
|
||||||
bold { underline { append(identifier) } }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
accountPart
|
|
||||||
}
|
|
||||||
subtitle.apply {
|
|
||||||
if (identifier != null && accountPart != null) {
|
|
||||||
text = accountPart
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onItemClicked { _, item ->
|
|
||||||
decryptAndFill(item)
|
|
||||||
}
|
|
||||||
layoutManager = LinearLayoutManager(context)
|
|
||||||
}
|
|
||||||
search.apply {
|
|
||||||
val initialSearch =
|
|
||||||
formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
|
|
||||||
setText(initialSearch, TextView.BufferType.EDITABLE)
|
|
||||||
addTextChangedListener { updateSearch() }
|
|
||||||
}
|
|
||||||
origin.text = buildSpannedString {
|
|
||||||
append(getString(R.string.oreo_autofill_select_and_fill_into))
|
|
||||||
append("\n")
|
|
||||||
bold {
|
|
||||||
append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
strictDomainSearch.apply {
|
|
||||||
visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE
|
|
||||||
isChecked = formOrigin is FormOrigin.Web
|
|
||||||
setOnCheckedChangeListener { _, _ -> updateSearch() }
|
|
||||||
}
|
|
||||||
shouldMatch.text = getString(
|
|
||||||
R.string.oreo_autofill_match_with,
|
|
||||||
formOrigin.getPrettyIdentifier(applicationContext)
|
|
||||||
)
|
|
||||||
model.searchResult.observe(this@AutofillFilterView) { result ->
|
|
||||||
val list = result.passwordItems
|
|
||||||
(rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) {
|
|
||||||
rvPassword.scrollToPosition(0)
|
|
||||||
}
|
|
||||||
// Switch RecyclerView out for a "no results" message if the new list is empty and
|
|
||||||
// the message is not yet shown (and vice versa).
|
|
||||||
if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) ||
|
|
||||||
(list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id)
|
|
||||||
) {
|
|
||||||
rvPasswordSwitcher.showNext()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateSearch() {
|
|
||||||
model.search(
|
|
||||||
binding.search.text.toString().trim(),
|
|
||||||
filterMode = if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy,
|
|
||||||
searchMode = SearchMode.RecursivelyInSubdirectories,
|
|
||||||
listMode = ListMode.FilesOnly
|
|
||||||
)
|
)
|
||||||
|
.intentSender
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var formOrigin: FormOrigin
|
||||||
|
private lateinit var directoryStructure: DirectoryStructure
|
||||||
|
private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate)
|
||||||
|
|
||||||
|
private val model: SearchableRepositoryViewModel by viewModels {
|
||||||
|
ViewModelProvider.AndroidViewModelFactory(application)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val decryptAction =
|
||||||
|
registerForActivityResult(StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode == RESULT_OK) {
|
||||||
|
setResult(RESULT_OK, result.data)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decryptAndFill(item: PasswordItem) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
|
super.onCreate(savedInstanceState)
|
||||||
if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor(
|
setContentView(binding.root)
|
||||||
applicationContext,
|
setFinishOnTouchOutside(true)
|
||||||
formOrigin,
|
|
||||||
item.file
|
val params = window.attributes
|
||||||
)
|
params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt()
|
||||||
// intent?.extras? is checked to be non-null in onCreate
|
params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
|
||||||
decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent(
|
window.attributes = params
|
||||||
item.file,
|
|
||||||
intent!!.extras!!,
|
if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
|
||||||
this
|
e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
|
||||||
))
|
finish()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
formOrigin =
|
||||||
|
when {
|
||||||
|
intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> {
|
||||||
|
FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!)
|
||||||
|
}
|
||||||
|
intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> {
|
||||||
|
FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" }
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
directoryStructure = AutofillPreferences.directoryStructure(this)
|
||||||
|
|
||||||
|
supportActionBar?.hide()
|
||||||
|
bindUI()
|
||||||
|
updateSearch()
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindUI() {
|
||||||
|
with(binding) {
|
||||||
|
rvPassword.apply {
|
||||||
|
adapter =
|
||||||
|
SearchableRepositoryAdapter(R.layout.oreo_autofill_filter_row, ::PasswordViewHolder) { item ->
|
||||||
|
val file = item.file.relativeTo(item.rootDir)
|
||||||
|
val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
|
||||||
|
val identifier = directoryStructure.getIdentifierFor(file)
|
||||||
|
val accountPart = directoryStructure.getAccountPartFor(file)
|
||||||
|
check(identifier != null || accountPart != null) {
|
||||||
|
"At least one of identifier and accountPart should always be non-null"
|
||||||
|
}
|
||||||
|
title.text =
|
||||||
|
if (identifier != null) {
|
||||||
|
buildSpannedString {
|
||||||
|
if (pathToIdentifier != null) append("$pathToIdentifier/")
|
||||||
|
bold { underline { append(identifier) } }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
accountPart
|
||||||
|
}
|
||||||
|
subtitle.apply {
|
||||||
|
if (identifier != null && accountPart != null) {
|
||||||
|
text = accountPart
|
||||||
|
visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onItemClicked { _, item -> decryptAndFill(item) }
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
}
|
||||||
|
search.apply {
|
||||||
|
val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
|
||||||
|
setText(initialSearch, TextView.BufferType.EDITABLE)
|
||||||
|
addTextChangedListener { updateSearch() }
|
||||||
|
}
|
||||||
|
origin.text =
|
||||||
|
buildSpannedString {
|
||||||
|
append(getString(R.string.oreo_autofill_select_and_fill_into))
|
||||||
|
append("\n")
|
||||||
|
bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) }
|
||||||
|
}
|
||||||
|
strictDomainSearch.apply {
|
||||||
|
visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE
|
||||||
|
isChecked = formOrigin is FormOrigin.Web
|
||||||
|
setOnCheckedChangeListener { _, _ -> updateSearch() }
|
||||||
|
}
|
||||||
|
shouldMatch.text =
|
||||||
|
getString(R.string.oreo_autofill_match_with, formOrigin.getPrettyIdentifier(applicationContext))
|
||||||
|
model.searchResult.observe(this@AutofillFilterView) { result ->
|
||||||
|
val list = result.passwordItems
|
||||||
|
(rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { rvPassword.scrollToPosition(0) }
|
||||||
|
// Switch RecyclerView out for a "no results" message if the new list is empty and
|
||||||
|
// the message is not yet shown (and vice versa).
|
||||||
|
if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) ||
|
||||||
|
(list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id)
|
||||||
|
) {
|
||||||
|
rvPasswordSwitcher.showNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSearch() {
|
||||||
|
model.search(
|
||||||
|
binding.search.text.toString().trim(),
|
||||||
|
filterMode = if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy,
|
||||||
|
searchMode = SearchMode.RecursivelyInSubdirectories,
|
||||||
|
listMode = ListMode.FilesOnly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decryptAndFill(item: PasswordItem) {
|
||||||
|
if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
|
||||||
|
if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file)
|
||||||
|
// intent?.extras? is checked to be non-null in onCreate
|
||||||
|
decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,84 +31,83 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
|
||||||
@TargetApi(Build.VERSION_CODES.O)
|
@TargetApi(Build.VERSION_CODES.O)
|
||||||
class AutofillPublisherChangedActivity : AppCompatActivity() {
|
class AutofillPublisherChangedActivity : AppCompatActivity() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val EXTRA_APP_PACKAGE =
|
private const val EXTRA_APP_PACKAGE = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE"
|
||||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE"
|
private const val EXTRA_FILL_RESPONSE_AFTER_RESET =
|
||||||
private const val EXTRA_FILL_RESPONSE_AFTER_RESET =
|
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET"
|
||||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET"
|
private var publisherChangedRequestCode = 1
|
||||||
private var publisherChangedRequestCode = 1
|
|
||||||
|
|
||||||
fun makePublisherChangedIntentSender(
|
fun makePublisherChangedIntentSender(
|
||||||
context: Context,
|
context: Context,
|
||||||
publisherChangedException: AutofillPublisherChangedException,
|
publisherChangedException: AutofillPublisherChangedException,
|
||||||
fillResponseAfterReset: FillResponse?,
|
fillResponseAfterReset: FillResponse?,
|
||||||
): IntentSender {
|
): IntentSender {
|
||||||
val intent = Intent(context, AutofillPublisherChangedActivity::class.java).apply {
|
val intent =
|
||||||
putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier)
|
Intent(context, AutofillPublisherChangedActivity::class.java).apply {
|
||||||
putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset)
|
putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier)
|
||||||
}
|
putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset)
|
||||||
return PendingIntent.getActivity(
|
|
||||||
context, publisherChangedRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT
|
|
||||||
).intentSender
|
|
||||||
}
|
}
|
||||||
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
publisherChangedRequestCode++,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_CANCEL_CURRENT
|
||||||
|
)
|
||||||
|
.intentSender
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private lateinit var appPackage: String
|
private lateinit var appPackage: String
|
||||||
private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate)
|
private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
setFinishOnTouchOutside(true)
|
setFinishOnTouchOutside(true)
|
||||||
|
|
||||||
appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run {
|
appPackage =
|
||||||
e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
|
intent.getStringExtra(EXTRA_APP_PACKAGE)
|
||||||
finish()
|
?: run {
|
||||||
return
|
e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
|
||||||
}
|
finish()
|
||||||
supportActionBar?.hide()
|
return
|
||||||
showPackageInfo()
|
|
||||||
with(binding) {
|
|
||||||
okButton.setOnClickListener { finish() }
|
|
||||||
advancedButton.setOnClickListener {
|
|
||||||
advancedButton.visibility = View.GONE
|
|
||||||
warningAppAdvancedInfo.visibility = View.VISIBLE
|
|
||||||
resetButton.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
resetButton.setOnClickListener {
|
|
||||||
AutofillMatcher.clearMatchesFor(this@AutofillPublisherChangedActivity, FormOrigin.App(appPackage))
|
|
||||||
val fillResponse = intent.getParcelableExtra<FillResponse>(EXTRA_FILL_RESPONSE_AFTER_RESET)
|
|
||||||
setResult(RESULT_OK, Intent().apply {
|
|
||||||
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse)
|
|
||||||
})
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
supportActionBar?.hide()
|
||||||
|
showPackageInfo()
|
||||||
|
with(binding) {
|
||||||
|
okButton.setOnClickListener { finish() }
|
||||||
|
advancedButton.setOnClickListener {
|
||||||
|
advancedButton.visibility = View.GONE
|
||||||
|
warningAppAdvancedInfo.visibility = View.VISIBLE
|
||||||
|
resetButton.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
resetButton.setOnClickListener {
|
||||||
|
AutofillMatcher.clearMatchesFor(this@AutofillPublisherChangedActivity, FormOrigin.App(appPackage))
|
||||||
|
val fillResponse = intent.getParcelableExtra<FillResponse>(EXTRA_FILL_RESPONSE_AFTER_RESET)
|
||||||
|
setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) })
|
||||||
|
finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun showPackageInfo() {
|
private fun showPackageInfo() {
|
||||||
runCatching {
|
runCatching {
|
||||||
with(binding) {
|
with(binding) {
|
||||||
val packageInfo =
|
val packageInfo = packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
|
||||||
packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
|
val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
|
||||||
val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
|
warningAppInstallDate.text = getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
|
||||||
warningAppInstallDate.text =
|
val appInfo = packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
|
||||||
getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
|
warningAppName.text = "“${packageManager.getApplicationLabel(appInfo)}”"
|
||||||
val appInfo =
|
|
||||||
packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
|
|
||||||
warningAppName.text = "“${packageManager.getApplicationLabel(appInfo)}”"
|
|
||||||
|
|
||||||
val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage)
|
val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage)
|
||||||
warningAppAdvancedInfo.text = getString(
|
warningAppAdvancedInfo.text =
|
||||||
R.string.oreo_autofill_warning_publisher_advanced_info_template,
|
getString(R.string.oreo_autofill_warning_publisher_advanced_info_template, appPackage, currentHash)
|
||||||
appPackage,
|
}
|
||||||
currentHash
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
e(e) { "Failed to retrieve package info for $appPackage" }
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
e(e) { "Failed to retrieve package info for $appPackage" }
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,121 +29,106 @@ import java.io.File
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
class AutofillSaveActivity : AppCompatActivity() {
|
class AutofillSaveActivity : AppCompatActivity() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val EXTRA_FOLDER_NAME =
|
private const val EXTRA_FOLDER_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME"
|
||||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME"
|
private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD"
|
||||||
private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD"
|
private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME"
|
||||||
private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME"
|
private const val EXTRA_SHOULD_MATCH_APP = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP"
|
||||||
private const val EXTRA_SHOULD_MATCH_APP =
|
private const val EXTRA_SHOULD_MATCH_WEB = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
|
||||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP"
|
private const val EXTRA_GENERATE_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
|
||||||
private const val EXTRA_SHOULD_MATCH_WEB =
|
|
||||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
|
|
||||||
private const val EXTRA_GENERATE_PASSWORD =
|
|
||||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
|
|
||||||
|
|
||||||
private var saveRequestCode = 1
|
private var saveRequestCode = 1
|
||||||
|
|
||||||
fun makeSaveIntentSender(
|
fun makeSaveIntentSender(context: Context, credentials: Credentials?, formOrigin: FormOrigin): IntentSender {
|
||||||
context: Context,
|
val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
|
||||||
credentials: Credentials?,
|
// Prevent directory traversals
|
||||||
formOrigin: FormOrigin
|
val sanitizedIdentifier =
|
||||||
): IntentSender {
|
identifier.replace('\\', '_').replace('/', '_').trimStart('.').takeUnless { it.isBlank() }
|
||||||
val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
|
?: formOrigin.identifier
|
||||||
// Prevent directory traversals
|
val directoryStructure = AutofillPreferences.directoryStructure(context)
|
||||||
val sanitizedIdentifier = identifier.replace('\\', '_')
|
val folderName =
|
||||||
.replace('/', '_')
|
directoryStructure.getSaveFolderName(
|
||||||
.trimStart('.')
|
sanitizedIdentifier = sanitizedIdentifier,
|
||||||
.takeUnless { it.isBlank() } ?: formOrigin.identifier
|
username = credentials?.username
|
||||||
val directoryStructure = AutofillPreferences.directoryStructure(context)
|
)
|
||||||
val folderName = directoryStructure.getSaveFolderName(
|
val fileName = directoryStructure.getSaveFileName(username = credentials?.username, identifier = identifier)
|
||||||
sanitizedIdentifier = sanitizedIdentifier,
|
val intent =
|
||||||
username = credentials?.username
|
Intent(context, AutofillSaveActivity::class.java).apply {
|
||||||
|
putExtras(
|
||||||
|
bundleOf(
|
||||||
|
EXTRA_FOLDER_NAME to folderName,
|
||||||
|
EXTRA_NAME to fileName,
|
||||||
|
EXTRA_PASSWORD to credentials?.password,
|
||||||
|
EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
|
||||||
|
EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
|
||||||
|
EXTRA_GENERATE_PASSWORD to (credentials == null)
|
||||||
)
|
)
|
||||||
val fileName = directoryStructure.getSaveFileName(username = credentials?.username, identifier = identifier)
|
)
|
||||||
val intent = Intent(context, AutofillSaveActivity::class.java).apply {
|
|
||||||
putExtras(
|
|
||||||
bundleOf(
|
|
||||||
EXTRA_FOLDER_NAME to folderName,
|
|
||||||
EXTRA_NAME to fileName,
|
|
||||||
EXTRA_PASSWORD to credentials?.password,
|
|
||||||
EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
|
|
||||||
EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
|
|
||||||
EXTRA_GENERATE_PASSWORD to (credentials == null)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return PendingIntent.getActivity(
|
|
||||||
context,
|
|
||||||
saveRequestCode++,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_CANCEL_CURRENT
|
|
||||||
).intentSender
|
|
||||||
}
|
}
|
||||||
|
return PendingIntent.getActivity(context, saveRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
|
||||||
|
.intentSender
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val formOrigin by lazy(LazyThreadSafetyMode.NONE) {
|
private val formOrigin by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP)
|
val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP)
|
||||||
val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB)
|
val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB)
|
||||||
if (shouldMatchApp != null && shouldMatchWeb == null) {
|
if (shouldMatchApp != null && shouldMatchWeb == null) {
|
||||||
FormOrigin.App(shouldMatchApp)
|
FormOrigin.App(shouldMatchApp)
|
||||||
} else if (shouldMatchApp == null && shouldMatchWeb != null) {
|
} else if (shouldMatchApp == null && shouldMatchWeb != null) {
|
||||||
FormOrigin.Web(shouldMatchWeb)
|
FormOrigin.Web(shouldMatchWeb)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val repo = PasswordRepository.getRepositoryDirectory()
|
val repo = PasswordRepository.getRepositoryDirectory()
|
||||||
val saveIntent = Intent(this, PasswordCreationActivity::class.java).apply {
|
val saveIntent =
|
||||||
putExtras(
|
Intent(this, PasswordCreationActivity::class.java).apply {
|
||||||
bundleOf(
|
putExtras(
|
||||||
"REPO_PATH" to repo.absolutePath,
|
bundleOf(
|
||||||
"FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
|
"REPO_PATH" to repo.absolutePath,
|
||||||
PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
|
"FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
|
||||||
PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
|
PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
|
||||||
PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
|
PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
|
||||||
)
|
PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
registerForActivityResult(StartActivityForResult()) { result ->
|
}
|
||||||
val data = result.data
|
registerForActivityResult(StartActivityForResult()) { result ->
|
||||||
if (result.resultCode == RESULT_OK && data != null) {
|
val data = result.data
|
||||||
val createdPath = data.getStringExtra("CREATED_FILE")!!
|
if (result.resultCode == RESULT_OK && data != null) {
|
||||||
formOrigin?.let {
|
val createdPath = data.getStringExtra("CREATED_FILE")!!
|
||||||
AutofillMatcher.addMatchFor(this, it, File(createdPath))
|
formOrigin?.let { AutofillMatcher.addMatchFor(this, it, File(createdPath)) }
|
||||||
}
|
val password = data.getStringExtra("PASSWORD")
|
||||||
val password = data.getStringExtra("PASSWORD")
|
val resultIntent =
|
||||||
val resultIntent = if (password != null) {
|
if (password != null) {
|
||||||
// Password was generated and should be filled into a form.
|
// Password was generated and should be filled into a form.
|
||||||
val username = data.getStringExtra("USERNAME")
|
val username = data.getStringExtra("USERNAME")
|
||||||
val clientState =
|
val clientState =
|
||||||
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
|
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
|
||||||
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
|
?: run {
|
||||||
finish()
|
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
|
||||||
return@registerForActivityResult
|
finish()
|
||||||
}
|
return@registerForActivityResult
|
||||||
val credentials = Credentials(username, password, null)
|
}
|
||||||
val fillInDataset = AutofillResponseBuilder.makeFillInDataset(
|
val credentials = Credentials(username, password, null)
|
||||||
this,
|
val fillInDataset =
|
||||||
credentials,
|
AutofillResponseBuilder.makeFillInDataset(this, credentials, clientState, AutofillAction.Generate)
|
||||||
clientState,
|
Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }
|
||||||
AutofillAction.Generate
|
|
||||||
)
|
|
||||||
Intent().apply {
|
|
||||||
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Password was extracted from a form, there is nothing to fill.
|
|
||||||
Intent()
|
|
||||||
}
|
|
||||||
setResult(RESULT_OK, resultIntent)
|
|
||||||
} else {
|
} else {
|
||||||
setResult(RESULT_CANCELED)
|
// Password was extracted from a form, there is nothing to fill.
|
||||||
|
Intent()
|
||||||
}
|
}
|
||||||
finish()
|
setResult(RESULT_OK, resultIntent)
|
||||||
}.launch(saveIntent)
|
} else {
|
||||||
}
|
setResult(RESULT_CANCELED)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
.launch(saveIntent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,6 @@ import dev.msfjarvis.aps.R
|
||||||
|
|
||||||
class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
|
||||||
val title: TextView = itemView.findViewById(R.id.title)
|
val title: TextView = itemView.findViewById(R.id.title)
|
||||||
val subtitle: TextView = itemView.findViewById(R.id.subtitle)
|
val subtitle: TextView = itemView.findViewById(R.id.subtitle)
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,269 +42,249 @@ import org.openintents.openpgp.OpenPgpError
|
||||||
@Suppress("Registered")
|
@Suppress("Registered")
|
||||||
open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
|
open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
|
|
||||||
/**
|
/** Full path to the repository */
|
||||||
* Full path to the repository
|
val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! }
|
||||||
*/
|
|
||||||
val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! }
|
|
||||||
|
|
||||||
/**
|
/** Full path to the password file being worked on */
|
||||||
* Full path to the password file being worked on
|
val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! }
|
||||||
*/
|
|
||||||
val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Name of the password file
|
* Name of the password file
|
||||||
*
|
*
|
||||||
* Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
|
* Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
|
||||||
*/
|
*/
|
||||||
val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension }
|
val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension }
|
||||||
|
|
||||||
/**
|
/** Get the timestamp for when this file was last modified. */
|
||||||
* Get the timestamp for when this file was last modified.
|
val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
*/
|
getLastChangedString(intent.getLongExtra("LAST_CHANGED_TIMESTAMP", -1L))
|
||||||
val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) {
|
}
|
||||||
getLastChangedString(
|
|
||||||
intent.getLongExtra(
|
/** [SharedPreferences] instance used by subclasses to persist settings */
|
||||||
"LAST_CHANGED_TIMESTAMP",
|
val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs }
|
||||||
-1L
|
|
||||||
)
|
/**
|
||||||
)
|
* Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain.
|
||||||
|
*/
|
||||||
|
private var serviceConnection: OpenPgpServiceConnection? = null
|
||||||
|
var api: OpenPgpApi? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with
|
||||||
|
* in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package.
|
||||||
|
*/
|
||||||
|
private var previousListener: OpenPgpServiceConnection.OnBound? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or
|
||||||
|
* recent apps screen.
|
||||||
|
*/
|
||||||
|
@CallSuper
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
tag(TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This is
|
||||||
|
* annotated with [CallSuper] because it's critical to unbind the service to ensure we're not
|
||||||
|
* leaking things.
|
||||||
|
*/
|
||||||
|
@CallSuper
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
serviceConnection?.unbindFromService()
|
||||||
|
previousListener = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [onResume] controls the flow for resumption of a PGP operation that was previously interrupted
|
||||||
|
* by the [OPENPGP_PROVIDER] package being missing.
|
||||||
|
*/
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
previousListener?.let { bindToOpenKeychain(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up [api] once the service is bound. Downstream consumers must call super this to
|
||||||
|
* initialize [api]
|
||||||
|
*/
|
||||||
|
@CallSuper
|
||||||
|
override fun onBound(service: IOpenPgpService2) {
|
||||||
|
api = OpenPgpApi(this, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle
|
||||||
|
* their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call
|
||||||
|
* super.
|
||||||
|
*/
|
||||||
|
override fun onError(e: Exception) {
|
||||||
|
e(e) { "Callers must handle their own exceptions" }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Method for subclasses to initiate binding with [OpenPgpServiceConnection]. */
|
||||||
|
fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) {
|
||||||
|
val installed =
|
||||||
|
runCatching {
|
||||||
|
packageManager.getPackageInfo(OPENPGP_PROVIDER, 0)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
.getOr(false)
|
||||||
|
if (!installed) {
|
||||||
|
previousListener = onBoundListener
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(getString(R.string.openkeychain_not_installed_title))
|
||||||
|
.setMessage(getString(R.string.openkeychain_not_installed_message))
|
||||||
|
.setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ ->
|
||||||
|
runCatching {
|
||||||
|
val intent =
|
||||||
|
Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER))
|
||||||
|
setPackage("com.android.vending")
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ ->
|
||||||
|
runCatching {
|
||||||
|
val intent =
|
||||||
|
Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER))
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setOnCancelListener { finish() }
|
||||||
|
.show()
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
previousListener = null
|
||||||
|
serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { it.bindToService() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the case where OpenKeychain returns that it needs to interact with the user
|
||||||
|
*
|
||||||
|
* @param result The intent returned by OpenKeychain
|
||||||
|
*/
|
||||||
|
fun getUserInteractionRequestIntent(result: Intent): IntentSender {
|
||||||
|
i { "RESULT_CODE_USER_INTERACTION_REQUIRED" }
|
||||||
|
return result.getParcelableExtra<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets a relative string describing when this shape was last changed (e.g. "one hour ago") */
|
||||||
|
private fun getLastChangedString(timeStamp: Long): CharSequence {
|
||||||
|
if (timeStamp < 0) {
|
||||||
|
throw RuntimeException()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
return DateUtils.getRelativeTimeSpanString(this, timeStamp, true)
|
||||||
* [SharedPreferences] instance used by subclasses to persist settings
|
}
|
||||||
*/
|
|
||||||
val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain.
|
* Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses can
|
||||||
*/
|
* use this when they want to default to sane error handling.
|
||||||
private var serviceConnection: OpenPgpServiceConnection? = null
|
*/
|
||||||
var api: OpenPgpApi? = null
|
fun handleError(result: Intent) {
|
||||||
|
val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR)
|
||||||
|
if (error != null) {
|
||||||
|
when (error.errorId) {
|
||||||
|
OpenPgpError.NO_OR_WRONG_PASSPHRASE -> {
|
||||||
|
snackbar(message = getString(R.string.openpgp_error_wrong_passphrase))
|
||||||
|
}
|
||||||
|
OpenPgpError.NO_USER_IDS -> {
|
||||||
|
snackbar(message = getString(R.string.openpgp_error_no_user_ids))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
snackbar(message = getString(R.string.openpgp_error_unknown, error.message))
|
||||||
|
e { "onError getErrorId: ${error.errorId}" }
|
||||||
|
e { "onError getMessage: ${error.message}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with
|
* Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing
|
||||||
* in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package.
|
* [showSnackbar] as false.
|
||||||
*/
|
*/
|
||||||
private var previousListener: OpenPgpServiceConnection.OnBound? = null
|
fun copyTextToClipboard(
|
||||||
|
text: String?,
|
||||||
|
showSnackbar: Boolean = true,
|
||||||
|
@StringRes snackbarTextRes: Int = R.string.clipboard_copied_text
|
||||||
|
) {
|
||||||
|
val clipboard = clipboard ?: return
|
||||||
|
val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
|
||||||
|
clipboard.setPrimaryClip(clip)
|
||||||
|
if (showSnackbar) {
|
||||||
|
snackbar(message = resources.getString(snackbarTextRes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots
|
* Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to hide
|
||||||
* or recent apps screen.
|
* the default [Snackbar] and starts off an instance of [ClipboardService] to provide a way of
|
||||||
*/
|
* clearing the clipboard.
|
||||||
@CallSuper
|
*/
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
fun copyPasswordToClipboard(password: String?) {
|
||||||
super.onCreate(savedInstanceState)
|
copyTextToClipboard(password, showSnackbar = false)
|
||||||
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
tag(TAG)
|
val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45
|
||||||
|
|
||||||
|
if (clearAfter != 0) {
|
||||||
|
val service =
|
||||||
|
Intent(this, ClipboardService::class.java).apply {
|
||||||
|
action = ClipboardService.ACTION_START
|
||||||
|
putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
startForegroundService(service)
|
||||||
|
} else {
|
||||||
|
startService(service)
|
||||||
|
}
|
||||||
|
snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter))
|
||||||
|
} else {
|
||||||
|
snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "APS/BasePgpActivity"
|
||||||
|
const val KEY_PWGEN_TYPE_CLASSIC = "classic"
|
||||||
|
const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
|
||||||
|
|
||||||
|
/** Gets the relative path to the repository */
|
||||||
|
fun getRelativePath(fullPath: String, repositoryPath: String): String =
|
||||||
|
fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
|
||||||
|
|
||||||
|
/** Gets the Parent path, relative to the repository */
|
||||||
|
fun getParentPath(fullPath: String, repositoryPath: String): String {
|
||||||
|
val relativePath = getRelativePath(fullPath, repositoryPath)
|
||||||
|
val index = relativePath.lastIndexOf("/")
|
||||||
|
return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** /path/to/store/social/facebook.gpg -> social/facebook */
|
||||||
* [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This
|
@JvmStatic
|
||||||
* is annotated with [CallSuper] because it's critical to unbind the service to ensure we're not
|
fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
|
||||||
* leaking things.
|
var relativePath = getRelativePath(fullPath, repositoryPath)
|
||||||
*/
|
return if (relativePath.isNotEmpty() && relativePath != "/") {
|
||||||
@CallSuper
|
// remove preceding '/'
|
||||||
override fun onDestroy() {
|
relativePath = relativePath.substring(1)
|
||||||
super.onDestroy()
|
if (relativePath.endsWith('/')) {
|
||||||
serviceConnection?.unbindFromService()
|
relativePath + basename
|
||||||
previousListener = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [onResume] controls the flow for resumption of a PGP operation that was previously interrupted
|
|
||||||
* by the [OPENPGP_PROVIDER] package being missing.
|
|
||||||
*/
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
previousListener?.let { bindToOpenKeychain(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up [api] once the service is bound. Downstream consumers must call super this to
|
|
||||||
* initialize [api]
|
|
||||||
*/
|
|
||||||
@CallSuper
|
|
||||||
override fun onBound(service: IOpenPgpService2) {
|
|
||||||
api = OpenPgpApi(this, service)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle
|
|
||||||
* their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call super.
|
|
||||||
*/
|
|
||||||
override fun onError(e: Exception) {
|
|
||||||
e(e) { "Callers must handle their own exceptions" }
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Method for subclasses to initiate binding with [OpenPgpServiceConnection].
|
|
||||||
*/
|
|
||||||
fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) {
|
|
||||||
val installed = runCatching {
|
|
||||||
packageManager.getPackageInfo(OPENPGP_PROVIDER, 0)
|
|
||||||
true
|
|
||||||
}.getOr(false)
|
|
||||||
if (!installed) {
|
|
||||||
previousListener = onBoundListener
|
|
||||||
MaterialAlertDialogBuilder(this)
|
|
||||||
.setTitle(getString(R.string.openkeychain_not_installed_title))
|
|
||||||
.setMessage(getString(R.string.openkeychain_not_installed_message))
|
|
||||||
.setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ ->
|
|
||||||
runCatching {
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER))
|
|
||||||
setPackage("com.android.vending")
|
|
||||||
}
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ ->
|
|
||||||
runCatching {
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER))
|
|
||||||
}
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.setOnCancelListener { finish() }
|
|
||||||
.show()
|
|
||||||
return
|
|
||||||
} else {
|
} else {
|
||||||
previousListener = null
|
"$relativePath/$basename"
|
||||||
serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also {
|
|
||||||
it.bindToService()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the case where OpenKeychain returns that it needs to interact with the user
|
|
||||||
*
|
|
||||||
* @param result The intent returned by OpenKeychain
|
|
||||||
*/
|
|
||||||
fun getUserInteractionRequestIntent(result: Intent): IntentSender {
|
|
||||||
i { "RESULT_CODE_USER_INTERACTION_REQUIRED" }
|
|
||||||
return result.getParcelableExtra<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a relative string describing when this shape was last changed
|
|
||||||
* (e.g. "one hour ago")
|
|
||||||
*/
|
|
||||||
private fun getLastChangedString(timeStamp: Long): CharSequence {
|
|
||||||
if (timeStamp < 0) {
|
|
||||||
throw RuntimeException()
|
|
||||||
}
|
|
||||||
|
|
||||||
return DateUtils.getRelativeTimeSpanString(this, timeStamp, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses
|
|
||||||
* can use this when they want to default to sane error handling.
|
|
||||||
*/
|
|
||||||
fun handleError(result: Intent) {
|
|
||||||
val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR)
|
|
||||||
if (error != null) {
|
|
||||||
when (error.errorId) {
|
|
||||||
OpenPgpError.NO_OR_WRONG_PASSPHRASE -> {
|
|
||||||
snackbar(message = getString(R.string.openpgp_error_wrong_passphrase))
|
|
||||||
}
|
|
||||||
OpenPgpError.NO_USER_IDS -> {
|
|
||||||
snackbar(message = getString(R.string.openpgp_error_no_user_ids))
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
snackbar(message = getString(R.string.openpgp_error_unknown, error.message))
|
|
||||||
e { "onError getErrorId: ${error.errorId}" }
|
|
||||||
e { "onError getMessage: ${error.message}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing
|
|
||||||
* [showSnackbar] as false.
|
|
||||||
*/
|
|
||||||
fun copyTextToClipboard(
|
|
||||||
text: String?,
|
|
||||||
showSnackbar: Boolean = true,
|
|
||||||
@StringRes snackbarTextRes: Int = R.string.clipboard_copied_text
|
|
||||||
) {
|
|
||||||
val clipboard = clipboard ?: return
|
|
||||||
val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
|
|
||||||
clipboard.setPrimaryClip(clip)
|
|
||||||
if (showSnackbar) {
|
|
||||||
snackbar(message = resources.getString(snackbarTextRes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to
|
|
||||||
* hide the default [Snackbar] and starts off an instance of [ClipboardService] to provide a
|
|
||||||
* way of clearing the clipboard.
|
|
||||||
*/
|
|
||||||
fun copyPasswordToClipboard(password: String?) {
|
|
||||||
copyTextToClipboard(password, showSnackbar = false)
|
|
||||||
|
|
||||||
val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45
|
|
||||||
|
|
||||||
if (clearAfter != 0) {
|
|
||||||
val service = Intent(this, ClipboardService::class.java).apply {
|
|
||||||
action = ClipboardService.ACTION_START
|
|
||||||
putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
startForegroundService(service)
|
|
||||||
} else {
|
|
||||||
startService(service)
|
|
||||||
}
|
|
||||||
snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter))
|
|
||||||
} else {
|
|
||||||
snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val TAG = "APS/BasePgpActivity"
|
|
||||||
const val KEY_PWGEN_TYPE_CLASSIC = "classic"
|
|
||||||
const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the relative path to the repository
|
|
||||||
*/
|
|
||||||
fun getRelativePath(fullPath: String, repositoryPath: String): String =
|
|
||||||
fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the Parent path, relative to the repository
|
|
||||||
*/
|
|
||||||
fun getParentPath(fullPath: String, repositoryPath: String): String {
|
|
||||||
val relativePath = getRelativePath(fullPath, repositoryPath)
|
|
||||||
val index = relativePath.lastIndexOf("/")
|
|
||||||
return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* /path/to/store/social/facebook.gpg -> social/facebook
|
|
||||||
*/
|
|
||||||
@JvmStatic
|
|
||||||
fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
|
|
||||||
var relativePath = getRelativePath(fullPath, repositoryPath)
|
|
||||||
return if (relativePath.isNotEmpty() && relativePath != "/") {
|
|
||||||
// remove preceding '/'
|
|
||||||
relativePath = relativePath.substring(1)
|
|
||||||
if (relativePath.endsWith('/')) {
|
|
||||||
relativePath + basename
|
|
||||||
} else {
|
|
||||||
"$relativePath/$basename"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
basename
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
basename
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,202 +37,196 @@ import org.openintents.openpgp.IOpenPgpService2
|
||||||
|
|
||||||
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
|
|
||||||
private val binding by viewBinding(DecryptLayoutBinding::inflate)
|
private val binding by viewBinding(DecryptLayoutBinding::inflate)
|
||||||
|
|
||||||
private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
|
private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
|
||||||
private var passwordEntry: PasswordEntry? = null
|
private var passwordEntry: PasswordEntry? = null
|
||||||
|
|
||||||
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result ->
|
private val userInteractionRequiredResult =
|
||||||
if (result.data == null) {
|
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||||
setResult(RESULT_CANCELED, null)
|
if (result.data == null) {
|
||||||
finish()
|
setResult(RESULT_CANCELED, null)
|
||||||
return@registerForActivityResult
|
|
||||||
}
|
|
||||||
|
|
||||||
when (result.resultCode) {
|
|
||||||
RESULT_OK -> decryptAndVerify(result.data)
|
|
||||||
RESULT_CANCELED -> {
|
|
||||||
setResult(RESULT_CANCELED, result.data)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
||||||
bindToOpenKeychain(this)
|
|
||||||
title = name
|
|
||||||
with(binding) {
|
|
||||||
setContentView(root)
|
|
||||||
passwordCategory.text = relativeParentPath
|
|
||||||
passwordFile.text = name
|
|
||||||
passwordFile.setOnLongClickListener {
|
|
||||||
copyTextToClipboard(name)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
passwordLastChanged.run {
|
|
||||||
runCatching {
|
|
||||||
text = resources.getString(R.string.last_changed, lastChangedString)
|
|
||||||
}.onFailure {
|
|
||||||
visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.pgp_handler, menu)
|
|
||||||
passwordEntry?.let { entry ->
|
|
||||||
if (menu != null) {
|
|
||||||
menu.findItem(R.id.edit_password).isVisible = true
|
|
||||||
if (entry.password.isNotEmpty()) {
|
|
||||||
menu.findItem(R.id.share_password_as_plaintext).isVisible = true
|
|
||||||
menu.findItem(R.id.copy_password).isVisible = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
when (item.itemId) {
|
|
||||||
android.R.id.home -> onBackPressed()
|
|
||||||
R.id.edit_password -> editPassword()
|
|
||||||
R.id.share_password_as_plaintext -> shareAsPlaintext()
|
|
||||||
R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password)
|
|
||||||
else -> return super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBound(service: IOpenPgpService2) {
|
|
||||||
super.onBound(service)
|
|
||||||
decryptAndVerify()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError(e: Exception) {
|
|
||||||
e(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatically finishes the activity 60 seconds after decryption succeeded to prevent
|
|
||||||
* information leaks from stale activities.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
private fun startAutoDismissTimer() {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
delay(60.seconds)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edit the current password and hide all the fields populated by encrypted data so that when
|
|
||||||
* the result triggers they can be repopulated with new data.
|
|
||||||
*/
|
|
||||||
private fun editPassword() {
|
|
||||||
val intent = Intent(this, PasswordCreationActivity::class.java)
|
|
||||||
intent.putExtra("FILE_PATH", relativeParentPath)
|
|
||||||
intent.putExtra("REPO_PATH", repoPath)
|
|
||||||
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
|
|
||||||
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
|
|
||||||
intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent)
|
|
||||||
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
|
|
||||||
startActivity(intent)
|
|
||||||
finish()
|
finish()
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
when (result.resultCode) {
|
||||||
|
RESULT_OK -> decryptAndVerify(result.data)
|
||||||
|
RESULT_CANCELED -> {
|
||||||
|
setResult(RESULT_CANCELED, result.data)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun shareAsPlaintext() {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
val sendIntent = Intent().apply {
|
super.onCreate(savedInstanceState)
|
||||||
action = Intent.ACTION_SEND
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
|
bindToOpenKeychain(this)
|
||||||
type = "text/plain"
|
title = name
|
||||||
|
with(binding) {
|
||||||
|
setContentView(root)
|
||||||
|
passwordCategory.text = relativeParentPath
|
||||||
|
passwordFile.text = name
|
||||||
|
passwordFile.setOnLongClickListener {
|
||||||
|
copyTextToClipboard(name)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
passwordLastChanged.run {
|
||||||
|
runCatching { text = resources.getString(R.string.last_changed, lastChangedString) }.onFailure {
|
||||||
|
visibility = View.GONE
|
||||||
}
|
}
|
||||||
// Always show a picker to give the user a chance to cancel
|
}
|
||||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)))
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTime::class)
|
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||||
private fun decryptAndVerify(receivedIntent: Intent? = null) {
|
menuInflater.inflate(R.menu.pgp_handler, menu)
|
||||||
if (api == null) {
|
passwordEntry?.let { entry ->
|
||||||
bindToOpenKeychain(this)
|
if (menu != null) {
|
||||||
return
|
menu.findItem(R.id.edit_password).isVisible = true
|
||||||
|
if (entry.password.isNotEmpty()) {
|
||||||
|
menu.findItem(R.id.share_password_as_plaintext).isVisible = true
|
||||||
|
menu.findItem(R.id.copy_password).isVisible = true
|
||||||
}
|
}
|
||||||
val data = receivedIntent ?: Intent()
|
}
|
||||||
data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
val inputStream = File(fullPath).inputStream()
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
val outputStream = ByteArrayOutputStream()
|
when (item.itemId) {
|
||||||
|
android.R.id.home -> onBackPressed()
|
||||||
|
R.id.edit_password -> editPassword()
|
||||||
|
R.id.share_password_as_plaintext -> shareAsPlaintext()
|
||||||
|
R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password)
|
||||||
|
else -> return super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
override fun onBound(service: IOpenPgpService2) {
|
||||||
api?.executeApiAsync(data, inputStream, outputStream) { result ->
|
super.onBound(service)
|
||||||
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
decryptAndVerify()
|
||||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
}
|
||||||
startAutoDismissTimer()
|
|
||||||
runCatching {
|
|
||||||
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
|
|
||||||
val entry = PasswordEntry(outputStream)
|
|
||||||
val items = arrayListOf<FieldItem>()
|
|
||||||
val adapter = FieldItemAdapter(emptyList(), showPassword) { text ->
|
|
||||||
copyTextToClipboard(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
|
override fun onError(e: Exception) {
|
||||||
copyPasswordToClipboard(entry.password)
|
e(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
passwordEntry = entry
|
/**
|
||||||
invalidateOptionsMenu()
|
* Automatically finishes the activity 60 seconds after decryption succeeded to prevent
|
||||||
|
* information leaks from stale activities.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
private fun startAutoDismissTimer() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
delay(60.seconds)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.password.isNotEmpty()) {
|
/**
|
||||||
items.add(FieldItem.createPasswordField(entry.password))
|
* Edit the current password and hide all the fields populated by encrypted data so that when the
|
||||||
}
|
* result triggers they can be repopulated with new data.
|
||||||
|
*/
|
||||||
|
private fun editPassword() {
|
||||||
|
val intent = Intent(this, PasswordCreationActivity::class.java)
|
||||||
|
intent.putExtra("FILE_PATH", relativeParentPath)
|
||||||
|
intent.putExtra("REPO_PATH", repoPath)
|
||||||
|
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
|
||||||
|
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
|
||||||
|
intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent)
|
||||||
|
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
|
||||||
|
startActivity(intent)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.hasTotp()) {
|
private fun shareAsPlaintext() {
|
||||||
launch(Dispatchers.IO) {
|
val sendIntent =
|
||||||
// Calculate the actual remaining time for the first pass
|
Intent().apply {
|
||||||
// then return to the standard 30 second affair.
|
action = Intent.ACTION_SEND
|
||||||
val remainingTime =
|
putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
|
||||||
entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod)
|
type = "text/plain"
|
||||||
withContext(Dispatchers.Main) {
|
}
|
||||||
val code = entry.calculateTotpCode() ?: "Error"
|
// Always show a picker to give the user a chance to cancel
|
||||||
items.add(FieldItem.createOtpField(code))
|
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)))
|
||||||
}
|
}
|
||||||
delay(remainingTime.seconds)
|
|
||||||
repeat(Int.MAX_VALUE) {
|
|
||||||
val code = entry.calculateTotpCode() ?: "Error"
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
adapter.updateOTPCode(code)
|
|
||||||
}
|
|
||||||
delay(30.seconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!entry.username.isNullOrEmpty()) {
|
@OptIn(ExperimentalTime::class)
|
||||||
items.add(FieldItem.createUsernameField(entry.username))
|
private fun decryptAndVerify(receivedIntent: Intent? = null) {
|
||||||
}
|
if (api == null) {
|
||||||
|
bindToOpenKeychain(this)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val data = receivedIntent ?: Intent()
|
||||||
|
data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY
|
||||||
|
|
||||||
if (entry.hasExtraContentWithoutAuthData()) {
|
val inputStream = File(fullPath).inputStream()
|
||||||
entry.extraContentMap.forEach { (key, value) ->
|
val outputStream = ByteArrayOutputStream()
|
||||||
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.recyclerView.adapter = adapter
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
adapter.updateItems(items)
|
api?.executeApiAsync(data, inputStream, outputStream) { result ->
|
||||||
}.onFailure { e ->
|
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||||
e(e)
|
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||||
}
|
startAutoDismissTimer()
|
||||||
}
|
runCatching {
|
||||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
|
||||||
val sender = getUserInteractionRequestIntent(result)
|
val entry = PasswordEntry(outputStream)
|
||||||
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
val items = arrayListOf<FieldItem>()
|
||||||
}
|
val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> copyTextToClipboard(text) }
|
||||||
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
|
||||||
|
if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
|
||||||
|
copyPasswordToClipboard(entry.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordEntry = entry
|
||||||
|
invalidateOptionsMenu()
|
||||||
|
|
||||||
|
if (entry.password.isNotEmpty()) {
|
||||||
|
items.add(FieldItem.createPasswordField(entry.password))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.hasTotp()) {
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
// Calculate the actual remaining time for the first pass
|
||||||
|
// then return to the standard 30 second affair.
|
||||||
|
val remainingTime = entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val code = entry.calculateTotpCode() ?: "Error"
|
||||||
|
items.add(FieldItem.createOtpField(code))
|
||||||
|
}
|
||||||
|
delay(remainingTime.seconds)
|
||||||
|
repeat(Int.MAX_VALUE) {
|
||||||
|
val code = entry.calculateTotpCode() ?: "Error"
|
||||||
|
withContext(Dispatchers.Main) { adapter.updateOTPCode(code) }
|
||||||
|
delay(30.seconds)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.username.isNullOrEmpty()) {
|
||||||
|
items.add(FieldItem.createUsernameField(entry.username))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.hasExtraContentWithoutAuthData()) {
|
||||||
|
entry.extraContentMap.forEach { (key, value) ->
|
||||||
|
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
adapter.updateItems(items)
|
||||||
}
|
}
|
||||||
|
.onFailure { e -> e(e) }
|
||||||
|
}
|
||||||
|
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||||
|
val sender = getUserInteractionRequestIntent(result)
|
||||||
|
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
||||||
|
}
|
||||||
|
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,56 +21,54 @@ import org.openintents.openpgp.IOpenPgpService2
|
||||||
|
|
||||||
class GetKeyIdsActivity : BasePgpActivity() {
|
class GetKeyIdsActivity : BasePgpActivity() {
|
||||||
|
|
||||||
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result ->
|
private val userInteractionRequiredResult =
|
||||||
if (result.data == null || result.resultCode == RESULT_CANCELED) {
|
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||||
setResult(RESULT_CANCELED, result.data)
|
if (result.data == null || result.resultCode == RESULT_CANCELED) {
|
||||||
finish()
|
setResult(RESULT_CANCELED, result.data)
|
||||||
return@registerForActivityResult
|
finish()
|
||||||
}
|
return@registerForActivityResult
|
||||||
getKeyIds(result.data!!)
|
}
|
||||||
|
getKeyIds(result.data!!)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
bindToOpenKeychain(this)
|
bindToOpenKeychain(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBound(service: IOpenPgpService2) {
|
override fun onBound(service: IOpenPgpService2) {
|
||||||
super.onBound(service)
|
super.onBound(service)
|
||||||
getKeyIds()
|
getKeyIds()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(e: Exception) {
|
override fun onError(e: Exception) {
|
||||||
e(e)
|
e(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Get the Key ids from OpenKeychain */
|
||||||
* Get the Key ids from OpenKeychain
|
private fun getKeyIds(data: Intent = Intent()) {
|
||||||
*/
|
data.action = OpenPgpApi.ACTION_GET_KEY_IDS
|
||||||
private fun getKeyIds(data: Intent = Intent()) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
data.action = OpenPgpApi.ACTION_GET_KEY_IDS
|
api?.executeApiAsync(data, null, null) { result ->
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||||
api?.executeApiAsync(data, null, null) { result ->
|
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||||
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
runCatching {
|
||||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
val ids =
|
||||||
runCatching {
|
result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { OpenPgpUtils.convertKeyIdToHex(it) }
|
||||||
val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map {
|
?: emptyList()
|
||||||
OpenPgpUtils.convertKeyIdToHex(it)
|
val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray())
|
||||||
} ?: emptyList()
|
setResult(RESULT_OK, keyResult)
|
||||||
val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray())
|
finish()
|
||||||
setResult(RESULT_OK, keyResult)
|
|
||||||
finish()
|
|
||||||
}.onFailure { e ->
|
|
||||||
e(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
|
||||||
val sender = getUserInteractionRequestIntent(result)
|
|
||||||
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
|
||||||
}
|
|
||||||
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.onFailure { e -> e(e) }
|
||||||
|
}
|
||||||
|
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||||
|
val sender = getUserInteractionRequestIntent(result)
|
||||||
|
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
||||||
|
}
|
||||||
|
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,454 +55,443 @@ import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
|
||||||
|
|
||||||
class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
|
|
||||||
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
|
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
|
||||||
|
|
||||||
private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
||||||
private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
|
private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
|
||||||
private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
|
private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
|
||||||
private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) }
|
private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) }
|
intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
|
||||||
private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
}
|
||||||
private var oldCategory: String? = null
|
private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) }
|
||||||
private var copy: Boolean = false
|
private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
||||||
private var encryptionIntent: Intent = Intent()
|
private var oldCategory: String? = null
|
||||||
|
private var copy: Boolean = false
|
||||||
|
private var encryptionIntent: Intent = Intent()
|
||||||
|
|
||||||
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result ->
|
private val userInteractionRequiredResult =
|
||||||
if (result.data == null) {
|
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||||
setResult(RESULT_CANCELED, null)
|
if (result.data == null) {
|
||||||
finish()
|
setResult(RESULT_CANCELED, null)
|
||||||
return@registerForActivityResult
|
finish()
|
||||||
}
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
when (result.resultCode) {
|
|
||||||
RESULT_OK -> encrypt(result.data)
|
when (result.resultCode) {
|
||||||
RESULT_CANCELED -> {
|
RESULT_OK -> encrypt(result.data)
|
||||||
setResult(RESULT_CANCELED, result.data)
|
RESULT_CANCELED -> {
|
||||||
finish()
|
setResult(RESULT_CANCELED, result.data)
|
||||||
}
|
finish()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val otpImportAction = registerForActivityResult(StartActivityForResult()) { result ->
|
private val otpImportAction =
|
||||||
if (result.resultCode == RESULT_OK) {
|
registerForActivityResult(StartActivityForResult()) { result ->
|
||||||
binding.otpImportButton.isVisible = false
|
if (result.resultCode == RESULT_OK) {
|
||||||
val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
|
binding.otpImportButton.isVisible = false
|
||||||
val contents = "${intentResult.contents}\n"
|
val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
|
||||||
|
val contents = "${intentResult.contents}\n"
|
||||||
|
val currentExtras = binding.extraContent.text.toString()
|
||||||
|
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') binding.extraContent.append("\n$contents")
|
||||||
|
else binding.extraContent.append(contents)
|
||||||
|
snackbar(message = getString(R.string.otp_import_success))
|
||||||
|
} else {
|
||||||
|
snackbar(message = getString(R.string.otp_import_failure))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gpgKeySelectAction =
|
||||||
|
registerForActivityResult(StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode == RESULT_OK) {
|
||||||
|
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
|
||||||
|
withContext(Dispatchers.IO) { gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) }
|
||||||
|
commitChange(
|
||||||
|
getString(
|
||||||
|
R.string.git_commit_gpg_id,
|
||||||
|
getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.onSuccess { encrypt(encryptionIntent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun File.findTillRoot(fileName: String, rootPath: File): File? {
|
||||||
|
val gpgFile = File(this, fileName)
|
||||||
|
if (gpgFile.exists()) return gpgFile
|
||||||
|
|
||||||
|
if (this.absolutePath == rootPath.absolutePath) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val parent = parentFile
|
||||||
|
return if (parent != null && parent.exists()) {
|
||||||
|
parent.findTillRoot(fileName, rootPath)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
bindToOpenKeychain(this)
|
||||||
|
title = if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title)
|
||||||
|
with(binding) {
|
||||||
|
setContentView(root)
|
||||||
|
generatePassword.setOnClickListener { generatePassword() }
|
||||||
|
otpImportButton.setOnClickListener {
|
||||||
|
supportFragmentManager.setFragmentResultListener(OTP_RESULT_REQUEST_KEY, this@PasswordCreationActivity) {
|
||||||
|
requestKey,
|
||||||
|
bundle ->
|
||||||
|
if (requestKey == OTP_RESULT_REQUEST_KEY) {
|
||||||
|
val contents = bundle.getString(RESULT)
|
||||||
val currentExtras = binding.extraContent.text.toString()
|
val currentExtras = binding.extraContent.text.toString()
|
||||||
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
|
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') binding.extraContent.append("\n$contents")
|
||||||
binding.extraContent.append("\n$contents")
|
else binding.extraContent.append(contents)
|
||||||
else
|
}
|
||||||
binding.extraContent.append(contents)
|
}
|
||||||
snackbar(message = getString(R.string.otp_import_success))
|
val items = arrayOf(getString(R.string.otp_import_qr_code), getString(R.string.otp_import_manual_entry))
|
||||||
|
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
||||||
|
.setItems(items) { _, index ->
|
||||||
|
if (index == 0) {
|
||||||
|
otpImportAction.launch(
|
||||||
|
IntentIntegrator(this@PasswordCreationActivity)
|
||||||
|
.setOrientationLocked(false)
|
||||||
|
.setBeepEnabled(false)
|
||||||
|
.setDesiredBarcodeFormats(QR_CODE)
|
||||||
|
.createScanIntent()
|
||||||
|
)
|
||||||
|
} else if (index == 1) {
|
||||||
|
OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
directoryInputLayout.apply {
|
||||||
|
if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
|
||||||
|
isEnabled = true
|
||||||
} else {
|
} else {
|
||||||
snackbar(message = getString(R.string.otp_import_failure))
|
setBackgroundColor(getColor(android.R.color.transparent))
|
||||||
}
|
}
|
||||||
}
|
val path = getRelativePath(fullPath, repoPath)
|
||||||
|
// Keep empty path field visible if it is editable.
|
||||||
private val gpgKeySelectAction = registerForActivityResult(StartActivityForResult()) { result ->
|
if (path.isEmpty() && !isEnabled) visibility = View.GONE
|
||||||
if (result.resultCode == RESULT_OK) {
|
else {
|
||||||
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
directory.setText(path)
|
||||||
lifecycleScope.launch {
|
oldCategory = path
|
||||||
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
gpgIdentifierFile.writeText((keyIds + "").joinToString("\n"))
|
|
||||||
}
|
|
||||||
commitChange(getString(
|
|
||||||
R.string.git_commit_gpg_id,
|
|
||||||
getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name)
|
|
||||||
)).onSuccess {
|
|
||||||
encrypt(encryptionIntent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (suggestedName != null) {
|
||||||
private fun File.findTillRoot(fileName: String, rootPath: File): File? {
|
filename.setText(suggestedName)
|
||||||
val gpgFile = File(this, fileName)
|
} else {
|
||||||
if (gpgFile.exists()) return gpgFile
|
filename.requestFocus()
|
||||||
|
}
|
||||||
if (this.absolutePath == rootPath.absolutePath) {
|
// Allow the user to quickly switch between storing the username as the filename or
|
||||||
return null
|
// in the encrypted extras. This only makes sense if the directory structure is
|
||||||
}
|
// FileBased.
|
||||||
|
if (suggestedName == null &&
|
||||||
val parent = parentFile
|
AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == DirectoryStructure.FileBased
|
||||||
return if (parent != null && parent.exists()) {
|
) {
|
||||||
parent.findTillRoot(fileName, rootPath)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
||||||
bindToOpenKeychain(this)
|
|
||||||
title = if (editing)
|
|
||||||
getString(R.string.edit_password)
|
|
||||||
else
|
|
||||||
getString(R.string.new_password_title)
|
|
||||||
with(binding) {
|
|
||||||
setContentView(root)
|
|
||||||
generatePassword.setOnClickListener { generatePassword() }
|
|
||||||
otpImportButton.setOnClickListener {
|
|
||||||
supportFragmentManager.setFragmentResultListener(OTP_RESULT_REQUEST_KEY, this@PasswordCreationActivity) { requestKey, bundle ->
|
|
||||||
if (requestKey == OTP_RESULT_REQUEST_KEY) {
|
|
||||||
val contents = bundle.getString(RESULT)
|
|
||||||
val currentExtras = binding.extraContent.text.toString()
|
|
||||||
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
|
|
||||||
binding.extraContent.append("\n$contents")
|
|
||||||
else
|
|
||||||
binding.extraContent.append(contents)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val items = arrayOf(getString(R.string.otp_import_qr_code), getString(R.string.otp_import_manual_entry))
|
|
||||||
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
|
||||||
.setItems(items) { _, index ->
|
|
||||||
if (index == 0) {
|
|
||||||
otpImportAction.launch(IntentIntegrator(this@PasswordCreationActivity)
|
|
||||||
.setOrientationLocked(false)
|
|
||||||
.setBeepEnabled(false)
|
|
||||||
.setDesiredBarcodeFormats(QR_CODE)
|
|
||||||
.createScanIntent())
|
|
||||||
} else if (index == 1) {
|
|
||||||
OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
directoryInputLayout.apply {
|
|
||||||
if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
|
|
||||||
isEnabled = true
|
|
||||||
} else {
|
|
||||||
setBackgroundColor(getColor(android.R.color.transparent))
|
|
||||||
}
|
|
||||||
val path = getRelativePath(fullPath, repoPath)
|
|
||||||
// Keep empty path field visible if it is editable.
|
|
||||||
if (path.isEmpty() && !isEnabled)
|
|
||||||
visibility = View.GONE
|
|
||||||
else {
|
|
||||||
directory.setText(path)
|
|
||||||
oldCategory = path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (suggestedName != null) {
|
|
||||||
filename.setText(suggestedName)
|
|
||||||
} else {
|
|
||||||
filename.requestFocus()
|
|
||||||
}
|
|
||||||
// Allow the user to quickly switch between storing the username as the filename or
|
|
||||||
// in the encrypted extras. This only makes sense if the directory structure is
|
|
||||||
// FileBased.
|
|
||||||
if (suggestedName == null &&
|
|
||||||
AutofillPreferences.directoryStructure(this@PasswordCreationActivity) ==
|
|
||||||
DirectoryStructure.FileBased
|
|
||||||
) {
|
|
||||||
encryptUsername.apply {
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
setOnClickListener {
|
|
||||||
if (isChecked) {
|
|
||||||
// User wants to enable username encryption, so we add it to the
|
|
||||||
// encrypted extras as the first line.
|
|
||||||
val username = filename.text.toString()
|
|
||||||
val extras = "username:$username\n${extraContent.text}"
|
|
||||||
|
|
||||||
filename.text?.clear()
|
|
||||||
extraContent.setText(extras)
|
|
||||||
} else {
|
|
||||||
// User wants to disable username encryption, so we extract the
|
|
||||||
// username from the encrypted extras and use it as the filename.
|
|
||||||
val entry = PasswordEntry("PASSWORD\n${extraContent.text}")
|
|
||||||
val username = entry.username
|
|
||||||
|
|
||||||
// username should not be null here by the logic in
|
|
||||||
// updateViewState, but it could still happen due to
|
|
||||||
// input lag.
|
|
||||||
if (username != null) {
|
|
||||||
filename.setText(username)
|
|
||||||
extraContent.setText(entry.extraContentWithoutAuthData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
listOf(filename, extraContent).forEach {
|
|
||||||
it.doOnTextChanged { _, _, _, _ -> updateViewState() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
suggestedPass?.let {
|
|
||||||
password.setText(it)
|
|
||||||
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
|
||||||
}
|
|
||||||
suggestedExtra?.let { extraContent.setText(it) }
|
|
||||||
if (shouldGeneratePassword) {
|
|
||||||
generatePassword()
|
|
||||||
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateViewState()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
when (item.itemId) {
|
|
||||||
android.R.id.home -> {
|
|
||||||
setResult(RESULT_CANCELED)
|
|
||||||
onBackPressed()
|
|
||||||
}
|
|
||||||
R.id.save_password -> {
|
|
||||||
copy = false
|
|
||||||
encrypt()
|
|
||||||
}
|
|
||||||
R.id.save_and_copy_password -> {
|
|
||||||
copy = true
|
|
||||||
encrypt()
|
|
||||||
}
|
|
||||||
else -> return super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generatePassword() {
|
|
||||||
supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { requestKey, bundle ->
|
|
||||||
if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
|
|
||||||
binding.password.setText(bundle.getString(RESULT))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
|
|
||||||
KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment()
|
|
||||||
.show(supportFragmentManager, "generator")
|
|
||||||
KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment()
|
|
||||||
.show(supportFragmentManager, "xkpwgenerator")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateViewState() = with(binding) {
|
|
||||||
// Use PasswordEntry to parse extras for username
|
|
||||||
val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
|
|
||||||
encryptUsername.apply {
|
encryptUsername.apply {
|
||||||
if (visibility != View.VISIBLE)
|
visibility = View.VISIBLE
|
||||||
return@apply
|
setOnClickListener {
|
||||||
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
if (isChecked) {
|
||||||
val hasUsernameInExtras = entry.hasUsername()
|
// User wants to enable username encryption, so we add it to the
|
||||||
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
|
// encrypted extras as the first line.
|
||||||
isChecked = hasUsernameInExtras
|
val username = filename.text.toString()
|
||||||
|
val extras = "username:$username\n${extraContent.text}"
|
||||||
|
|
||||||
|
filename.text?.clear()
|
||||||
|
extraContent.setText(extras)
|
||||||
|
} else {
|
||||||
|
// User wants to disable username encryption, so we extract the
|
||||||
|
// username from the encrypted extras and use it as the filename.
|
||||||
|
val entry = PasswordEntry("PASSWORD\n${extraContent.text}")
|
||||||
|
val username = entry.username
|
||||||
|
|
||||||
|
// username should not be null here by the logic in
|
||||||
|
// updateViewState, but it could still happen due to
|
||||||
|
// input lag.
|
||||||
|
if (username != null) {
|
||||||
|
filename.setText(username)
|
||||||
|
extraContent.setText(entry.extraContentWithoutAuthData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
otpImportButton.isVisible = !entry.hasTotp()
|
listOf(filename, extraContent).forEach { it.doOnTextChanged { _, _, _, _ -> updateViewState() } }
|
||||||
|
}
|
||||||
|
suggestedPass?.let {
|
||||||
|
password.setText(it)
|
||||||
|
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
}
|
||||||
|
suggestedExtra?.let { extraContent.setText(it) }
|
||||||
|
if (shouldGeneratePassword) {
|
||||||
|
generatePassword()
|
||||||
|
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateViewState()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||||
|
menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
when (item.itemId) {
|
||||||
|
android.R.id.home -> {
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
onBackPressed()
|
||||||
|
}
|
||||||
|
R.id.save_password -> {
|
||||||
|
copy = false
|
||||||
|
encrypt()
|
||||||
|
}
|
||||||
|
R.id.save_and_copy_password -> {
|
||||||
|
copy = true
|
||||||
|
encrypt()
|
||||||
|
}
|
||||||
|
else -> return super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generatePassword() {
|
||||||
|
supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { requestKey, bundle ->
|
||||||
|
if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
|
||||||
|
binding.password.setText(bundle.getString(RESULT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
|
||||||
|
KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
|
||||||
|
KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment().show(supportFragmentManager, "xkpwgenerator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateViewState() =
|
||||||
|
with(binding) {
|
||||||
|
// Use PasswordEntry to parse extras for username
|
||||||
|
val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
|
||||||
|
encryptUsername.apply {
|
||||||
|
if (visibility != View.VISIBLE) return@apply
|
||||||
|
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
||||||
|
val hasUsernameInExtras = entry.hasUsername()
|
||||||
|
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
|
||||||
|
isChecked = hasUsernameInExtras
|
||||||
|
}
|
||||||
|
otpImportButton.isVisible = !entry.hasTotp()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Encrypts the password and the extra content */
|
||||||
* Encrypts the password and the extra content
|
private fun encrypt(receivedIntent: Intent? = null) {
|
||||||
*/
|
with(binding) {
|
||||||
private fun encrypt(receivedIntent: Intent? = null) {
|
val editName = filename.text.toString().trim()
|
||||||
with(binding) {
|
val editPass = password.text.toString()
|
||||||
val editName = filename.text.toString().trim()
|
val editExtra = extraContent.text.toString()
|
||||||
val editPass = password.text.toString()
|
|
||||||
val editExtra = extraContent.text.toString()
|
|
||||||
|
|
||||||
if (editName.isEmpty()) {
|
if (editName.isEmpty()) {
|
||||||
snackbar(message = resources.getString(R.string.file_toast_text))
|
snackbar(message = resources.getString(R.string.file_toast_text))
|
||||||
return@with
|
return@with
|
||||||
} else if (editName.contains('/')) {
|
} else if (editName.contains('/')) {
|
||||||
snackbar(message = resources.getString(R.string.invalid_filename_text))
|
snackbar(message = resources.getString(R.string.invalid_filename_text))
|
||||||
return@with
|
return@with
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editPass.isEmpty() && editExtra.isEmpty()) {
|
if (editPass.isEmpty() && editExtra.isEmpty()) {
|
||||||
snackbar(message = resources.getString(R.string.empty_toast_text))
|
snackbar(message = resources.getString(R.string.empty_toast_text))
|
||||||
return@with
|
return@with
|
||||||
}
|
}
|
||||||
|
|
||||||
if (copy) {
|
if (copy) {
|
||||||
copyPasswordToClipboard(editPass)
|
copyPasswordToClipboard(editPass)
|
||||||
}
|
}
|
||||||
|
|
||||||
encryptionIntent = receivedIntent ?: Intent()
|
encryptionIntent = receivedIntent ?: Intent()
|
||||||
encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT
|
encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT
|
||||||
|
|
||||||
// pass enters the key ID into `.gpg-id`.
|
// pass enters the key ID into `.gpg-id`.
|
||||||
val repoRoot = PasswordRepository.getRepositoryDirectory()
|
val repoRoot = PasswordRepository.getRepositoryDirectory()
|
||||||
val gpgIdentifierFile = File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot)
|
val gpgIdentifierFile =
|
||||||
?: File(repoRoot, ".gpg-id").apply { createNewFile() }
|
File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot)
|
||||||
val gpgIdentifiers = gpgIdentifierFile.readLines()
|
?: File(repoRoot, ".gpg-id").apply { createNewFile() }
|
||||||
.filter { it.isNotBlank() }
|
val gpgIdentifiers =
|
||||||
.map { line ->
|
gpgIdentifierFile.readLines().filter { it.isNotBlank() }.map { line ->
|
||||||
GpgIdentifier.fromString(line) ?: run {
|
GpgIdentifier.fromString(line)
|
||||||
// The line being empty means this is most likely an empty `.gpg-id` file
|
?: run {
|
||||||
// we created. Skip the validation so we can make the user add a real ID.
|
// The line being empty means this is most likely an empty `.gpg-id`
|
||||||
if (line.isEmpty()) return@run
|
// file
|
||||||
if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
|
// we created. Skip the validation so we can make the user add a real
|
||||||
snackbar(message = resources.getString(R.string.short_key_ids_unsupported))
|
// ID.
|
||||||
} else {
|
if (line.isEmpty()) return@run
|
||||||
snackbar(message = resources.getString(R.string.invalid_gpg_id))
|
if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
|
||||||
}
|
snackbar(message = resources.getString(R.string.short_key_ids_unsupported))
|
||||||
return@with
|
} else {
|
||||||
}
|
snackbar(message = resources.getString(R.string.invalid_gpg_id))
|
||||||
}
|
}
|
||||||
if (gpgIdentifiers.isEmpty()) {
|
return@with
|
||||||
gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java))
|
|
||||||
return@with
|
|
||||||
}
|
|
||||||
val keyIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.KeyId>().map { it.id }.toLongArray()
|
|
||||||
if (keyIds.isNotEmpty()) {
|
|
||||||
encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds)
|
|
||||||
}
|
|
||||||
val userIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.UserId>().map { it.email }.toTypedArray()
|
|
||||||
if (userIds.isNotEmpty()) {
|
|
||||||
encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true)
|
|
||||||
|
|
||||||
val content = "$editPass\n$editExtra"
|
|
||||||
val inputStream = ByteArrayInputStream(content.toByteArray())
|
|
||||||
val outputStream = ByteArrayOutputStream()
|
|
||||||
|
|
||||||
val path = when {
|
|
||||||
// If we allowed the user to edit the relative path, we have to consider it here instead
|
|
||||||
// of fullPath.
|
|
||||||
directoryInputLayout.isEnabled -> {
|
|
||||||
val editRelativePath = directory.text.toString().trim()
|
|
||||||
if (editRelativePath.isEmpty()) {
|
|
||||||
snackbar(message = resources.getString(R.string.path_toast_text))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}")
|
|
||||||
if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) {
|
|
||||||
snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
"${passwordDirectory.path}/$editName.gpg"
|
|
||||||
}
|
|
||||||
else -> "$fullPath/$editName.gpg"
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
api?.executeApiAsync(encryptionIntent, inputStream, outputStream) { result ->
|
|
||||||
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
|
||||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
|
||||||
runCatching {
|
|
||||||
val file = File(path)
|
|
||||||
// If we're not editing, this file should not already exist!
|
|
||||||
// Additionally, if we were editing and the incoming and outgoing
|
|
||||||
// filenames differ, it means we renamed. Ensure that the target
|
|
||||||
// doesn't already exist to prevent an accidental overwrite.
|
|
||||||
if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists()) {
|
|
||||||
snackbar(message = getString(R.string.password_creation_duplicate_error))
|
|
||||||
return@executeApiAsync
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!file.isInsideRepository()) {
|
|
||||||
snackbar(message = getString(R.string.message_error_destination_outside_repo))
|
|
||||||
return@executeApiAsync
|
|
||||||
}
|
|
||||||
|
|
||||||
file.outputStream().use {
|
|
||||||
it.write(outputStream.toByteArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
//associate the new password name with the last name's timestamp in history
|
|
||||||
val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
|
||||||
val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64()
|
|
||||||
val timestamp = preference.getString(oldFilePathHash)
|
|
||||||
if (timestamp != null) {
|
|
||||||
preference.edit {
|
|
||||||
remove(oldFilePathHash)
|
|
||||||
putString(file.absolutePath.base64(), timestamp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val returnIntent = Intent()
|
|
||||||
returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
|
|
||||||
returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
|
|
||||||
returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName))
|
|
||||||
|
|
||||||
if (shouldGeneratePassword) {
|
|
||||||
val directoryStructure =
|
|
||||||
AutofillPreferences.directoryStructure(applicationContext)
|
|
||||||
val entry = PasswordEntry(content)
|
|
||||||
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
|
|
||||||
val username = entry.username
|
|
||||||
?: directoryStructure.getUsernameFor(file)
|
|
||||||
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (directoryInputLayout.isVisible && directoryInputLayout.isEnabled && oldFileName != null) {
|
|
||||||
val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg")
|
|
||||||
if (oldFile.path != file.path && !oldFile.delete()) {
|
|
||||||
setResult(RESULT_CANCELED)
|
|
||||||
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
|
||||||
.setTitle(R.string.password_creation_file_fail_title)
|
|
||||||
.setMessage(getString(R.string.password_creation_file_delete_fail_message, oldFileName))
|
|
||||||
.setCancelable(false)
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
return@executeApiAsync
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text
|
|
||||||
lifecycleScope.launch {
|
|
||||||
commitChange(resources.getString(
|
|
||||||
commitMessageRes,
|
|
||||||
getLongName(fullPath, repoPath, editName)
|
|
||||||
)).onSuccess {
|
|
||||||
setResult(RESULT_OK, returnIntent)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}.onFailure { e ->
|
|
||||||
if (e is IOException) {
|
|
||||||
e(e) { "Failed to write password file" }
|
|
||||||
setResult(RESULT_CANCELED)
|
|
||||||
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
|
||||||
.setTitle(getString(R.string.password_creation_file_fail_title))
|
|
||||||
.setMessage(getString(R.string.password_creation_file_write_fail_message))
|
|
||||||
.setCancelable(false)
|
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
} else {
|
|
||||||
e(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
|
||||||
val sender = getUserInteractionRequestIntent(result)
|
|
||||||
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
|
||||||
}
|
|
||||||
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (gpgIdentifiers.isEmpty()) {
|
||||||
|
gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java))
|
||||||
|
return@with
|
||||||
|
}
|
||||||
|
val keyIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.KeyId>().map { it.id }.toLongArray()
|
||||||
|
if (keyIds.isNotEmpty()) {
|
||||||
|
encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds)
|
||||||
|
}
|
||||||
|
val userIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.UserId>().map { it.email }.toTypedArray()
|
||||||
|
if (userIds.isNotEmpty()) {
|
||||||
|
encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true)
|
||||||
|
|
||||||
private const val KEY_PWGEN_TYPE_CLASSIC = "classic"
|
val content = "$editPass\n$editExtra"
|
||||||
private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
|
val inputStream = ByteArrayInputStream(content.toByteArray())
|
||||||
const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
|
val outputStream = ByteArrayOutputStream()
|
||||||
const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
|
|
||||||
const val RESULT = "RESULT"
|
val path =
|
||||||
const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE"
|
when {
|
||||||
const val RETURN_EXTRA_NAME = "NAME"
|
// If we allowed the user to edit the relative path, we have to consider it here
|
||||||
const val RETURN_EXTRA_LONG_NAME = "LONG_NAME"
|
// instead
|
||||||
const val RETURN_EXTRA_USERNAME = "USERNAME"
|
// of fullPath.
|
||||||
const val RETURN_EXTRA_PASSWORD = "PASSWORD"
|
directoryInputLayout.isEnabled -> {
|
||||||
const val EXTRA_FILE_NAME = "FILENAME"
|
val editRelativePath = directory.text.toString().trim()
|
||||||
const val EXTRA_PASSWORD = "PASSWORD"
|
if (editRelativePath.isEmpty()) {
|
||||||
const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
|
snackbar(message = resources.getString(R.string.path_toast_text))
|
||||||
const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"
|
return
|
||||||
const val EXTRA_EDITING = "EDITING"
|
}
|
||||||
|
val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}")
|
||||||
|
if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) {
|
||||||
|
snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
"${passwordDirectory.path}/$editName.gpg"
|
||||||
|
}
|
||||||
|
else -> "$fullPath/$editName.gpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
api?.executeApiAsync(encryptionIntent, inputStream, outputStream) { result ->
|
||||||
|
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||||
|
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||||
|
runCatching {
|
||||||
|
val file = File(path)
|
||||||
|
// If we're not editing, this file should not already exist!
|
||||||
|
// Additionally, if we were editing and the incoming and outgoing
|
||||||
|
// filenames differ, it means we renamed. Ensure that the target
|
||||||
|
// doesn't already exist to prevent an accidental overwrite.
|
||||||
|
if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists()) {
|
||||||
|
snackbar(message = getString(R.string.password_creation_duplicate_error))
|
||||||
|
return@executeApiAsync
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.isInsideRepository()) {
|
||||||
|
snackbar(message = getString(R.string.message_error_destination_outside_repo))
|
||||||
|
return@executeApiAsync
|
||||||
|
}
|
||||||
|
|
||||||
|
file.outputStream().use { it.write(outputStream.toByteArray()) }
|
||||||
|
|
||||||
|
// associate the new password name with the last name's timestamp in
|
||||||
|
// history
|
||||||
|
val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
||||||
|
val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64()
|
||||||
|
val timestamp = preference.getString(oldFilePathHash)
|
||||||
|
if (timestamp != null) {
|
||||||
|
preference.edit {
|
||||||
|
remove(oldFilePathHash)
|
||||||
|
putString(file.absolutePath.base64(), timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val returnIntent = Intent()
|
||||||
|
returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
|
||||||
|
returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
|
||||||
|
returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName))
|
||||||
|
|
||||||
|
if (shouldGeneratePassword) {
|
||||||
|
val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
|
||||||
|
val entry = PasswordEntry(content)
|
||||||
|
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
|
||||||
|
val username = entry.username ?: directoryStructure.getUsernameFor(file)
|
||||||
|
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directoryInputLayout.isVisible && directoryInputLayout.isEnabled && oldFileName != null) {
|
||||||
|
val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg")
|
||||||
|
if (oldFile.path != file.path && !oldFile.delete()) {
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
||||||
|
.setTitle(R.string.password_creation_file_fail_title)
|
||||||
|
.setMessage(getString(R.string.password_creation_file_delete_fail_message, oldFileName))
|
||||||
|
.setCancelable(false)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
|
||||||
|
.show()
|
||||||
|
return@executeApiAsync
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text
|
||||||
|
lifecycleScope.launch {
|
||||||
|
commitChange(resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName)))
|
||||||
|
.onSuccess {
|
||||||
|
setResult(RESULT_OK, returnIntent)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
if (e is IOException) {
|
||||||
|
e(e) { "Failed to write password file" }
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
||||||
|
.setTitle(getString(R.string.password_creation_file_fail_title))
|
||||||
|
.setMessage(getString(R.string.password_creation_file_write_fail_message))
|
||||||
|
.setCancelable(false)
|
||||||
|
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
e(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||||
|
val sender = getUserInteractionRequestIntent(result)
|
||||||
|
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
||||||
|
}
|
||||||
|
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val KEY_PWGEN_TYPE_CLASSIC = "classic"
|
||||||
|
private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
|
||||||
|
const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
|
||||||
|
const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
|
||||||
|
const val RESULT = "RESULT"
|
||||||
|
const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE"
|
||||||
|
const val RETURN_EXTRA_NAME = "NAME"
|
||||||
|
const val RETURN_EXTRA_LONG_NAME = "LONG_NAME"
|
||||||
|
const val RETURN_EXTRA_USERNAME = "USERNAME"
|
||||||
|
const val RETURN_EXTRA_PASSWORD = "PASSWORD"
|
||||||
|
const val EXTRA_FILE_NAME = "FILENAME"
|
||||||
|
const val EXTRA_PASSWORD = "PASSWORD"
|
||||||
|
const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
|
||||||
|
const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"
|
||||||
|
const val EXTRA_EDITING = "EDITING"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,138 +25,136 @@ import dev.msfjarvis.aps.util.extensions.resolveAttribute
|
||||||
import dev.msfjarvis.aps.util.extensions.viewBinding
|
import dev.msfjarvis.aps.util.extensions.viewBinding
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like
|
* [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API
|
||||||
* API through [Builder] to create a similar UI, just at the bottom of the screen.
|
* through [Builder] to create a similar UI, just at the bottom of the screen.
|
||||||
*/
|
*/
|
||||||
class BasicBottomSheet private constructor(
|
class BasicBottomSheet
|
||||||
val title: String?,
|
private constructor(
|
||||||
val message: String,
|
val title: String?,
|
||||||
val positiveButtonLabel: String?,
|
val message: String,
|
||||||
val negativeButtonLabel: String?,
|
val positiveButtonLabel: String?,
|
||||||
val positiveButtonClickListener: View.OnClickListener?,
|
val negativeButtonLabel: String?,
|
||||||
val negativeButtonClickListener: View.OnClickListener?,
|
val positiveButtonClickListener: View.OnClickListener?,
|
||||||
|
val negativeButtonClickListener: View.OnClickListener?,
|
||||||
) : BottomSheetDialogFragment() {
|
) : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
private val binding by viewBinding(BasicBottomSheetBinding::bind)
|
private val binding by viewBinding(BasicBottomSheetBinding::bind)
|
||||||
|
|
||||||
private var behavior: BottomSheetBehavior<FrameLayout>? = null
|
private var behavior: BottomSheetBehavior<FrameLayout>? = null
|
||||||
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
|
private val bottomSheetCallback =
|
||||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||||
|
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
||||||
|
|
||||||
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||||
|
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||||
|
dismiss()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
if (savedInstanceState != null) dismiss()
|
||||||
dismiss()
|
return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
view.viewTreeObserver.addOnGlobalLayoutListener(
|
||||||
|
object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||||
|
val dialog = dialog as BottomSheetDialog? ?: return
|
||||||
|
behavior = dialog.behavior
|
||||||
|
behavior?.apply {
|
||||||
|
state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
peekHeight = 0
|
||||||
|
addBottomSheetCallback(bottomSheetCallback)
|
||||||
|
}
|
||||||
|
if (!title.isNullOrEmpty()) {
|
||||||
|
binding.bottomSheetTitle.isVisible = true
|
||||||
|
binding.bottomSheetTitle.text = title
|
||||||
|
}
|
||||||
|
binding.bottomSheetMessage.text = message
|
||||||
|
if (positiveButtonClickListener != null) {
|
||||||
|
positiveButtonLabel?.let { buttonLbl -> binding.bottomSheetOkButton.text = buttonLbl }
|
||||||
|
binding.bottomSheetOkButton.isVisible = true
|
||||||
|
binding.bottomSheetOkButton.setOnClickListener {
|
||||||
|
positiveButtonClickListener.onClick(it)
|
||||||
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (negativeButtonClickListener != null) {
|
||||||
|
binding.bottomSheetCancelButton.isVisible = true
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
negativeButtonLabel?.let { buttonLbl -> binding.bottomSheetCancelButton.text = buttonLbl }
|
||||||
if (savedInstanceState != null) dismiss()
|
binding.bottomSheetCancelButton.setOnClickListener {
|
||||||
return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
|
negativeButtonClickListener.onClick(it)
|
||||||
}
|
dismiss()
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
|
||||||
override fun onGlobalLayout() {
|
|
||||||
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
|
||||||
val dialog = dialog as BottomSheetDialog? ?: return
|
|
||||||
behavior = dialog.behavior
|
|
||||||
behavior?.apply {
|
|
||||||
state = BottomSheetBehavior.STATE_EXPANDED
|
|
||||||
peekHeight = 0
|
|
||||||
addBottomSheetCallback(bottomSheetCallback)
|
|
||||||
}
|
|
||||||
if (!title.isNullOrEmpty()) {
|
|
||||||
binding.bottomSheetTitle.isVisible = true
|
|
||||||
binding.bottomSheetTitle.text = title
|
|
||||||
}
|
|
||||||
binding.bottomSheetMessage.text = message
|
|
||||||
if (positiveButtonClickListener != null) {
|
|
||||||
positiveButtonLabel?.let { buttonLbl ->
|
|
||||||
binding.bottomSheetOkButton.text = buttonLbl
|
|
||||||
}
|
|
||||||
binding.bottomSheetOkButton.isVisible = true
|
|
||||||
binding.bottomSheetOkButton.setOnClickListener {
|
|
||||||
positiveButtonClickListener.onClick(it)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (negativeButtonClickListener != null) {
|
|
||||||
binding.bottomSheetCancelButton.isVisible = true
|
|
||||||
negativeButtonLabel?.let { buttonLbl ->
|
|
||||||
binding.bottomSheetCancelButton.text = buttonLbl
|
|
||||||
}
|
|
||||||
binding.bottomSheetCancelButton.setOnClickListener {
|
|
||||||
negativeButtonClickListener.onClick(it)
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
val gradientDrawable = GradientDrawable().apply {
|
|
||||||
setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
|
|
||||||
}
|
}
|
||||||
view.background = gradientDrawable
|
}
|
||||||
|
)
|
||||||
|
val gradientDrawable =
|
||||||
|
GradientDrawable().apply { setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) }
|
||||||
|
view.background = gradientDrawable
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dismiss() {
|
||||||
|
super.dismiss()
|
||||||
|
behavior?.removeBottomSheetCallback(bottomSheetCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Builder(val context: Context) {
|
||||||
|
|
||||||
|
private var title: String? = null
|
||||||
|
private var message: String? = null
|
||||||
|
private var positiveButtonLabel: String? = null
|
||||||
|
private var negativeButtonLabel: String? = null
|
||||||
|
private var positiveButtonClickListener: View.OnClickListener? = null
|
||||||
|
private var negativeButtonClickListener: View.OnClickListener? = null
|
||||||
|
|
||||||
|
fun setTitleRes(@StringRes titleRes: Int): Builder {
|
||||||
|
this.title = context.resources.getString(titleRes)
|
||||||
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun dismiss() {
|
fun setTitle(title: String): Builder {
|
||||||
super.dismiss()
|
this.title = title
|
||||||
behavior?.removeBottomSheetCallback(bottomSheetCallback)
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
class Builder(val context: Context) {
|
fun setMessageRes(@StringRes messageRes: Int): Builder {
|
||||||
|
this.message = context.resources.getString(messageRes)
|
||||||
private var title: String? = null
|
return this
|
||||||
private var message: String? = null
|
|
||||||
private var positiveButtonLabel: String? = null
|
|
||||||
private var negativeButtonLabel: String? = null
|
|
||||||
private var positiveButtonClickListener: View.OnClickListener? = null
|
|
||||||
private var negativeButtonClickListener: View.OnClickListener? = null
|
|
||||||
|
|
||||||
fun setTitleRes(@StringRes titleRes: Int): Builder {
|
|
||||||
this.title = context.resources.getString(titleRes)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTitle(title: String): Builder {
|
|
||||||
this.title = title
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMessageRes(@StringRes messageRes: Int): Builder {
|
|
||||||
this.message = context.resources.getString(messageRes)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setMessage(message: String): Builder {
|
|
||||||
this.message = message
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPositiveButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
|
|
||||||
this.positiveButtonClickListener = listener
|
|
||||||
this.positiveButtonLabel = buttonLabel
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNegativeButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
|
|
||||||
this.negativeButtonClickListener = listener
|
|
||||||
this.negativeButtonLabel = buttonLabel
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
fun build(): BasicBottomSheet {
|
|
||||||
require(message != null) { "Message needs to be set" }
|
|
||||||
return BasicBottomSheet(
|
|
||||||
title,
|
|
||||||
message!!,
|
|
||||||
positiveButtonLabel,
|
|
||||||
negativeButtonLabel,
|
|
||||||
positiveButtonClickListener,
|
|
||||||
negativeButtonClickListener
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMessage(message: String): Builder {
|
||||||
|
this.message = message
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPositiveButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
|
||||||
|
this.positiveButtonClickListener = listener
|
||||||
|
this.positiveButtonLabel = buttonLabel
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setNegativeButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
|
||||||
|
this.negativeButtonClickListener = listener
|
||||||
|
this.negativeButtonLabel = buttonLabel
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(): BasicBottomSheet {
|
||||||
|
require(message != null) { "Message needs to be set" }
|
||||||
|
return BasicBottomSheet(
|
||||||
|
title,
|
||||||
|
message!!,
|
||||||
|
positiveButtonLabel,
|
||||||
|
negativeButtonLabel,
|
||||||
|
positiveButtonClickListener,
|
||||||
|
negativeButtonClickListener
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,77 +30,82 @@ import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||||
|
|
||||||
class FolderCreationDialogFragment : DialogFragment() {
|
class FolderCreationDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
private lateinit var newFolder: File
|
private lateinit var newFolder: File
|
||||||
|
|
||||||
private val keySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val keySelectAction =
|
||||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||||
val gpgIdentifierFile = File(newFolder, ".gpg-id")
|
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
||||||
gpgIdentifierFile.writeText(keyIds.joinToString("\n"))
|
val gpgIdentifierFile = File(newFolder, ".gpg-id")
|
||||||
val repo = PasswordRepository.getRepository(null)
|
gpgIdentifierFile.writeText(keyIds.joinToString("\n"))
|
||||||
if (repo != null) {
|
val repo = PasswordRepository.getRepository(null)
|
||||||
lifecycleScope.launch {
|
if (repo != null) {
|
||||||
val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath
|
lifecycleScope.launch {
|
||||||
requireActivity().commitChange(
|
val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath
|
||||||
getString(
|
requireActivity()
|
||||||
R.string.git_commit_gpg_id,
|
.commitChange(
|
||||||
BasePgpActivity.getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name)
|
getString(
|
||||||
),
|
R.string.git_commit_gpg_id,
|
||||||
)
|
BasePgpActivity.getLongName(
|
||||||
dismiss()
|
gpgIdentifierFile.parentFile!!.absolutePath,
|
||||||
}
|
repoPath,
|
||||||
}
|
gpgIdentifierFile.name
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dismiss()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
|
val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
|
||||||
alertDialogBuilder.setTitle(R.string.title_create_folder)
|
alertDialogBuilder.setTitle(R.string.title_create_folder)
|
||||||
alertDialogBuilder.setView(R.layout.folder_dialog_fragment)
|
alertDialogBuilder.setView(R.layout.folder_dialog_fragment)
|
||||||
alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null)
|
alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null)
|
||||||
alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ ->
|
alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> dismiss() }
|
||||||
dismiss()
|
val dialog = alertDialogBuilder.create()
|
||||||
}
|
dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
|
||||||
val dialog = alertDialogBuilder.create()
|
dialog.setOnShowListener {
|
||||||
dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||||
dialog.setOnShowListener {
|
createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!)
|
||||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
}
|
||||||
createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dialog
|
|
||||||
}
|
}
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
private fun createDirectory(currentDir: String) {
|
private fun createDirectory(currentDir: String) {
|
||||||
val dialog = requireDialog()
|
val dialog = requireDialog()
|
||||||
val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
|
val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
|
||||||
val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
|
val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
|
||||||
newFolder = File("$currentDir/${folderNameView.text}")
|
newFolder = File("$currentDir/${folderNameView.text}")
|
||||||
folderNameViewContainer.error = when {
|
folderNameViewContainer.error =
|
||||||
newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
|
when {
|
||||||
newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
|
newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
|
||||||
else -> null
|
newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
|
||||||
}
|
else -> null
|
||||||
if (folderNameViewContainer.error != null) return
|
}
|
||||||
newFolder.mkdirs()
|
if (folderNameViewContainer.error != null) return
|
||||||
(requireActivity() as PasswordStore).refreshPasswordList(newFolder)
|
newFolder.mkdirs()
|
||||||
if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
|
(requireActivity() as PasswordStore).refreshPasswordList(newFolder)
|
||||||
keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
|
if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
|
||||||
return
|
keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
|
||||||
} else {
|
return
|
||||||
dismiss()
|
} else {
|
||||||
}
|
dismiss()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY"
|
private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY"
|
||||||
fun newInstance(startingDirectory: String): FolderCreationDialogFragment {
|
fun newInstance(startingDirectory: String): FolderCreationDialogFragment {
|
||||||
val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory)
|
val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory)
|
||||||
val fragment = FolderCreationDialogFragment()
|
val fragment = FolderCreationDialogFragment()
|
||||||
fragment.arguments = extras
|
fragment.arguments = extras
|
||||||
return fragment
|
return fragment
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,53 +25,54 @@ import dev.msfjarvis.aps.util.extensions.resolveAttribute
|
||||||
|
|
||||||
class ItemCreationBottomSheet : BottomSheetDialogFragment() {
|
class ItemCreationBottomSheet : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
private var behavior: BottomSheetBehavior<FrameLayout>? = null
|
private var behavior: BottomSheetBehavior<FrameLayout>? = null
|
||||||
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
|
private val bottomSheetCallback =
|
||||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||||
|
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
||||||
|
|
||||||
|
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||||
|
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||||
|
dismiss()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
if (savedInstanceState != null) dismiss()
|
||||||
dismiss()
|
return inflater.inflate(R.layout.item_create_sheet, container, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
view.viewTreeObserver.addOnGlobalLayoutListener(
|
||||||
|
object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||||
|
val dialog = dialog as BottomSheetDialog? ?: return
|
||||||
|
behavior = dialog.behavior
|
||||||
|
behavior?.apply {
|
||||||
|
state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
peekHeight = 0
|
||||||
|
addBottomSheetCallback(bottomSheetCallback)
|
||||||
|
}
|
||||||
|
dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener {
|
||||||
|
setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER))
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
dialog.findViewById<View>(R.id.create_password)?.setOnClickListener {
|
||||||
|
setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD))
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
val gradientDrawable =
|
||||||
|
GradientDrawable().apply { setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) }
|
||||||
|
view.background = gradientDrawable
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
override fun dismiss() {
|
||||||
if (savedInstanceState != null) dismiss()
|
super.dismiss()
|
||||||
return inflater.inflate(R.layout.item_create_sheet, container, false)
|
behavior?.removeBottomSheetCallback(bottomSheetCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
|
||||||
override fun onGlobalLayout() {
|
|
||||||
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
|
||||||
val dialog = dialog as BottomSheetDialog? ?: return
|
|
||||||
behavior = dialog.behavior
|
|
||||||
behavior?.apply {
|
|
||||||
state = BottomSheetBehavior.STATE_EXPANDED
|
|
||||||
peekHeight = 0
|
|
||||||
addBottomSheetCallback(bottomSheetCallback)
|
|
||||||
}
|
|
||||||
dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener {
|
|
||||||
setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER))
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
dialog.findViewById<View>(R.id.create_password)?.setOnClickListener {
|
|
||||||
setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD))
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
val gradientDrawable = GradientDrawable().apply {
|
|
||||||
setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
|
|
||||||
}
|
|
||||||
view.background = gradientDrawable
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun dismiss() {
|
|
||||||
super.dismiss()
|
|
||||||
behavior?.removeBottomSheetCallback(bottomSheetCallback)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,32 +20,30 @@ import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView
|
||||||
|
|
||||||
class OtpImportDialogFragment : DialogFragment() {
|
class OtpImportDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater)
|
val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater)
|
||||||
builder.setView(binding.root)
|
builder.setView(binding.root)
|
||||||
builder.setPositiveButton(android.R.string.ok) { _, _ ->
|
builder.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
setFragmentResult(
|
setFragmentResult(
|
||||||
PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
|
PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
|
||||||
bundleOf(
|
bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding))
|
||||||
PasswordCreationActivity.RESULT to getTOTPUri(binding)
|
)
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val dialog = builder.create()
|
|
||||||
dialog.requestInputFocusOnView<TextInputEditText>(R.id.secret)
|
|
||||||
return dialog
|
|
||||||
}
|
}
|
||||||
|
val dialog = builder.create()
|
||||||
|
dialog.requestInputFocusOnView<TextInputEditText>(R.id.secret)
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String {
|
private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String {
|
||||||
val secret = binding.secret.text.toString()
|
val secret = binding.secret.text.toString()
|
||||||
val account = binding.account.text.toString()
|
val account = binding.account.text.toString()
|
||||||
if (secret.isBlank()) return ""
|
if (secret.isBlank()) return ""
|
||||||
val builder = Uri.Builder()
|
val builder = Uri.Builder()
|
||||||
builder.scheme("otpauth")
|
builder.scheme("otpauth")
|
||||||
builder.authority("totp")
|
builder.authority("totp")
|
||||||
builder.appendQueryParameter("secret", secret)
|
builder.appendQueryParameter("secret", secret)
|
||||||
if (account.isNotBlank()) builder.appendQueryParameter("issuer", account)
|
if (account.isNotBlank()) builder.appendQueryParameter("issuer", account)
|
||||||
return builder.build().toString()
|
return builder.build().toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,72 +31,70 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
|
|
||||||
class PasswordGeneratorDialogFragment : DialogFragment() {
|
class PasswordGeneratorDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
val callingActivity = requireActivity()
|
val callingActivity = requireActivity()
|
||||||
val binding = FragmentPwgenBinding.inflate(layoutInflater)
|
val binding = FragmentPwgenBinding.inflate(layoutInflater)
|
||||||
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
|
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
|
||||||
val prefs = requireActivity().applicationContext
|
val prefs = requireActivity().applicationContext.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
||||||
.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
builder.setView(binding.root)
|
builder.setView(binding.root)
|
||||||
|
|
||||||
binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false)
|
binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false)
|
||||||
binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false)
|
binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false)
|
||||||
binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false)
|
binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false)
|
||||||
binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false)
|
binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false)
|
||||||
binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false)
|
binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false)
|
||||||
binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
|
binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
|
||||||
|
|
||||||
binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
|
binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
|
||||||
binding.passwordText.typeface = monoTypeface
|
binding.passwordText.typeface = monoTypeface
|
||||||
return builder.run {
|
return builder
|
||||||
setTitle(R.string.pwgen_title)
|
.run {
|
||||||
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
setTitle(R.string.pwgen_title)
|
||||||
setFragmentResult(
|
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||||
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
|
setFragmentResult(
|
||||||
bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}")
|
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
|
||||||
)
|
bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}")
|
||||||
}
|
)
|
||||||
setNeutralButton(R.string.dialog_cancel) { _, _ -> }
|
|
||||||
setNegativeButton(R.string.pwgen_generate, null)
|
|
||||||
create()
|
|
||||||
}.apply {
|
|
||||||
setOnShowListener {
|
|
||||||
generate(binding.passwordText)
|
|
||||||
getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
|
||||||
generate(binding.passwordText)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
setNeutralButton(R.string.dialog_cancel) { _, _ -> }
|
||||||
|
setNegativeButton(R.string.pwgen_generate, null)
|
||||||
private fun generate(passwordField: AppCompatTextView) {
|
create()
|
||||||
setPreferences()
|
}
|
||||||
passwordField.text = runCatching {
|
.apply {
|
||||||
generate(requireContext().applicationContext)
|
setOnShowListener {
|
||||||
}.getOrElse { e ->
|
generate(binding.passwordText)
|
||||||
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
|
getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generate(binding.passwordText) }
|
||||||
""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun isChecked(@IdRes id: Int): Boolean {
|
private fun generate(passwordField: AppCompatTextView) {
|
||||||
return requireDialog().findViewById<CheckBox>(id).isChecked
|
setPreferences()
|
||||||
}
|
passwordField.text =
|
||||||
|
runCatching { generate(requireContext().applicationContext) }.getOrElse { e ->
|
||||||
|
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun setPreferences() {
|
private fun isChecked(@IdRes id: Int): Boolean {
|
||||||
val preferences = listOfNotNull(
|
return requireDialog().findViewById<CheckBox>(id).isChecked
|
||||||
PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) },
|
}
|
||||||
PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) },
|
|
||||||
PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) },
|
private fun setPreferences() {
|
||||||
PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) },
|
val preferences =
|
||||||
PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) },
|
listOfNotNull(
|
||||||
PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) }
|
PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) },
|
||||||
)
|
PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) },
|
||||||
val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString()
|
PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) },
|
||||||
val length = lengthText.toIntOrNull()?.takeIf { it >= 0 }
|
PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) },
|
||||||
?: PasswordGenerator.DEFAULT_LENGTH
|
PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) },
|
||||||
setPrefs(requireActivity().applicationContext, preferences, length)
|
PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) }
|
||||||
}
|
)
|
||||||
|
val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString()
|
||||||
|
val length = lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH
|
||||||
|
setPrefs(requireActivity().applicationContext, preferences, length)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,103 +27,102 @@ import dev.msfjarvis.aps.util.extensions.getString
|
||||||
import dev.msfjarvis.aps.util.pwgenxkpwd.CapsType
|
import dev.msfjarvis.aps.util.pwgenxkpwd.CapsType
|
||||||
import dev.msfjarvis.aps.util.pwgenxkpwd.PasswordBuilder
|
import dev.msfjarvis.aps.util.pwgenxkpwd.PasswordBuilder
|
||||||
|
|
||||||
/** A placeholder fragment containing a simple view. */
|
/** A placeholder fragment containing a simple view. */
|
||||||
class XkPasswordGeneratorDialogFragment : DialogFragment() {
|
class XkPasswordGeneratorDialogFragment : DialogFragment() {
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
val callingActivity = requireActivity()
|
val callingActivity = requireActivity()
|
||||||
val inflater = callingActivity.layoutInflater
|
val inflater = callingActivity.layoutInflater
|
||||||
val binding = FragmentXkpwgenBinding.inflate(inflater)
|
val binding = FragmentXkpwgenBinding.inflate(inflater)
|
||||||
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
|
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
|
||||||
val prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
val prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
builder.setView(binding.root)
|
builder.setView(binding.root)
|
||||||
|
|
||||||
val previousStoredCapStyle: String = runCatching {
|
val previousStoredCapStyle: String =
|
||||||
prefs.getString(PREF_KEY_CAPITALS_STYLE)!!
|
runCatching { prefs.getString(PREF_KEY_CAPITALS_STYLE)!! }.getOr(DEFAULT_CAPS_STYLE)
|
||||||
}.getOr(DEFAULT_CAPS_STYLE)
|
|
||||||
|
|
||||||
val lastCapitalsStyleIndex: Int = runCatching {
|
val lastCapitalsStyleIndex: Int =
|
||||||
CapsType.valueOf(previousStoredCapStyle).ordinal
|
runCatching { CapsType.valueOf(previousStoredCapStyle).ordinal }.getOr(DEFAULT_CAPS_INDEX)
|
||||||
}.getOr(DEFAULT_CAPS_INDEX)
|
binding.xkCapType.setSelection(lastCapitalsStyleIndex)
|
||||||
binding.xkCapType.setSelection(lastCapitalsStyleIndex)
|
binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS))
|
||||||
binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS))
|
|
||||||
|
|
||||||
binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR))
|
binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR))
|
||||||
binding.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK))
|
binding.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK))
|
||||||
|
|
||||||
binding.xkPasswordText.typeface = monoTypeface
|
binding.xkPasswordText.typeface = monoTypeface
|
||||||
|
|
||||||
builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ ->
|
builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ ->
|
||||||
setPreferences(binding, prefs)
|
setPreferences(binding, prefs)
|
||||||
setFragmentResult(
|
setFragmentResult(
|
||||||
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
|
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
|
||||||
bundleOf(PasswordCreationActivity.RESULT to "${binding.xkPasswordText.text}")
|
bundleOf(PasswordCreationActivity.RESULT to "${binding.xkPasswordText.text}")
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// flip neutral and negative buttons
|
|
||||||
builder.setNeutralButton(resources.getString(R.string.dialog_cancel)) { _, _ -> }
|
|
||||||
builder.setNegativeButton(resources.getString(R.string.pwgen_generate), null)
|
|
||||||
|
|
||||||
val dialog = builder.setTitle(this.resources.getString(R.string.xkpwgen_title)).create()
|
|
||||||
|
|
||||||
dialog.setOnShowListener {
|
|
||||||
setPreferences(binding, prefs)
|
|
||||||
makeAndSetPassword(binding)
|
|
||||||
|
|
||||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
|
||||||
setPreferences(binding, prefs)
|
|
||||||
makeAndSetPassword(binding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dialog
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun makeAndSetPassword(binding: FragmentXkpwgenBinding) {
|
// flip neutral and negative buttons
|
||||||
PasswordBuilder(requireContext())
|
builder.setNeutralButton(resources.getString(R.string.dialog_cancel)) { _, _ -> }
|
||||||
.setNumberOfWords(Integer.valueOf(binding.xkNumWords.text.toString()))
|
builder.setNegativeButton(resources.getString(R.string.pwgen_generate), null)
|
||||||
.setMinimumWordLength(DEFAULT_MIN_WORD_LENGTH)
|
|
||||||
.setMaximumWordLength(DEFAULT_MAX_WORD_LENGTH)
|
|
||||||
.setSeparator(binding.xkSeparator.text.toString())
|
|
||||||
.appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT })
|
|
||||||
.appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL })
|
|
||||||
.setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString())).create()
|
|
||||||
.fold(
|
|
||||||
success = { binding.xkPasswordText.text = it },
|
|
||||||
failure = { e ->
|
|
||||||
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
|
|
||||||
tag("xkpw").e(e, "failure generating xkpasswd")
|
|
||||||
binding.xkPasswordText.text = FALLBACK_ERROR_PASS
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setPreferences(binding: FragmentXkpwgenBinding, prefs: SharedPreferences) {
|
val dialog = builder.setTitle(this.resources.getString(R.string.xkpwgen_title)).create()
|
||||||
prefs.edit {
|
|
||||||
putString(PREF_KEY_CAPITALS_STYLE, binding.xkCapType.selectedItem.toString())
|
|
||||||
putString(PREF_KEY_NUM_WORDS, binding.xkNumWords.text.toString())
|
|
||||||
putString(PREF_KEY_SEPARATOR, binding.xkSeparator.text.toString())
|
|
||||||
putString(PREF_KEY_EXTRA_SYMBOLS_MASK, binding.xkNumberSymbolMask.text.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
dialog.setOnShowListener {
|
||||||
|
setPreferences(binding, prefs)
|
||||||
|
makeAndSetPassword(binding)
|
||||||
|
|
||||||
const val PREF_KEY_CAPITALS_STYLE = "pref_key_capitals_style"
|
dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||||||
const val PREF_KEY_NUM_WORDS = "pref_key_num_words"
|
setPreferences(binding, prefs)
|
||||||
const val PREF_KEY_SEPARATOR = "pref_key_separator"
|
makeAndSetPassword(binding)
|
||||||
const val PREF_KEY_EXTRA_SYMBOLS_MASK = "pref_key_xkpwgen_extra_symbols_mask"
|
}
|
||||||
val DEFAULT_CAPS_STYLE = CapsType.Sentence.name
|
|
||||||
val DEFAULT_CAPS_INDEX = CapsType.Sentence.ordinal
|
|
||||||
const val DEFAULT_NUMBER_OF_WORDS = "3"
|
|
||||||
const val DEFAULT_WORD_SEPARATOR = "."
|
|
||||||
const val DEFAULT_EXTRA_SYMBOLS_MASK = "ds"
|
|
||||||
const val DEFAULT_MIN_WORD_LENGTH = 3
|
|
||||||
const val DEFAULT_MAX_WORD_LENGTH = 9
|
|
||||||
const val FALLBACK_ERROR_PASS = "42"
|
|
||||||
const val EXTRA_CHAR_PLACEHOLDER_DIGIT = 'd'
|
|
||||||
const val EXTRA_CHAR_PLACEHOLDER_SYMBOL = 's'
|
|
||||||
}
|
}
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeAndSetPassword(binding: FragmentXkpwgenBinding) {
|
||||||
|
PasswordBuilder(requireContext())
|
||||||
|
.setNumberOfWords(Integer.valueOf(binding.xkNumWords.text.toString()))
|
||||||
|
.setMinimumWordLength(DEFAULT_MIN_WORD_LENGTH)
|
||||||
|
.setMaximumWordLength(DEFAULT_MAX_WORD_LENGTH)
|
||||||
|
.setSeparator(binding.xkSeparator.text.toString())
|
||||||
|
.appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT })
|
||||||
|
.appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL })
|
||||||
|
.setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString()))
|
||||||
|
.create()
|
||||||
|
.fold(
|
||||||
|
success = { binding.xkPasswordText.text = it },
|
||||||
|
failure = { e ->
|
||||||
|
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
|
||||||
|
tag("xkpw").e(e, "failure generating xkpasswd")
|
||||||
|
binding.xkPasswordText.text = FALLBACK_ERROR_PASS
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPreferences(binding: FragmentXkpwgenBinding, prefs: SharedPreferences) {
|
||||||
|
prefs.edit {
|
||||||
|
putString(PREF_KEY_CAPITALS_STYLE, binding.xkCapType.selectedItem.toString())
|
||||||
|
putString(PREF_KEY_NUM_WORDS, binding.xkNumWords.text.toString())
|
||||||
|
putString(PREF_KEY_SEPARATOR, binding.xkSeparator.text.toString())
|
||||||
|
putString(PREF_KEY_EXTRA_SYMBOLS_MASK, binding.xkNumberSymbolMask.text.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val PREF_KEY_CAPITALS_STYLE = "pref_key_capitals_style"
|
||||||
|
const val PREF_KEY_NUM_WORDS = "pref_key_num_words"
|
||||||
|
const val PREF_KEY_SEPARATOR = "pref_key_separator"
|
||||||
|
const val PREF_KEY_EXTRA_SYMBOLS_MASK = "pref_key_xkpwgen_extra_symbols_mask"
|
||||||
|
val DEFAULT_CAPS_STYLE = CapsType.Sentence.name
|
||||||
|
val DEFAULT_CAPS_INDEX = CapsType.Sentence.ordinal
|
||||||
|
const val DEFAULT_NUMBER_OF_WORDS = "3"
|
||||||
|
const val DEFAULT_WORD_SEPARATOR = "."
|
||||||
|
const val DEFAULT_EXTRA_SYMBOLS_MASK = "ds"
|
||||||
|
const val DEFAULT_MIN_WORD_LENGTH = 3
|
||||||
|
const val DEFAULT_MAX_WORD_LENGTH = 9
|
||||||
|
const val FALLBACK_ERROR_PASS = "42"
|
||||||
|
const val EXTRA_CHAR_PLACEHOLDER_DIGIT = 'd'
|
||||||
|
const val EXTRA_CHAR_PLACEHOLDER_SYMBOL = 's'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,49 +15,46 @@ import dev.msfjarvis.aps.data.repo.PasswordRepository
|
||||||
import dev.msfjarvis.aps.ui.passwords.PASSWORD_FRAGMENT_TAG
|
import dev.msfjarvis.aps.ui.passwords.PASSWORD_FRAGMENT_TAG
|
||||||
import dev.msfjarvis.aps.ui.passwords.PasswordStore
|
import dev.msfjarvis.aps.ui.passwords.PasswordStore
|
||||||
|
|
||||||
|
|
||||||
class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
|
class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
|
||||||
|
|
||||||
private lateinit var passwordList: SelectFolderFragment
|
private lateinit var passwordList: SelectFolderFragment
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
passwordList = SelectFolderFragment()
|
passwordList = SelectFolderFragment()
|
||||||
val args = Bundle()
|
val args = Bundle()
|
||||||
args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
|
args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
|
||||||
|
|
||||||
passwordList.arguments = args
|
passwordList.arguments = args
|
||||||
|
|
||||||
supportActionBar?.show()
|
supportActionBar?.show()
|
||||||
|
|
||||||
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
|
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
|
||||||
|
|
||||||
supportFragmentManager.commit {
|
supportFragmentManager.commit { replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) }
|
||||||
replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG)
|
}
|
||||||
}
|
|
||||||
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
|
menuInflater.inflate(R.menu.pgp_handler_select_folder, menu)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
when (item.itemId) {
|
||||||
|
android.R.id.home -> {
|
||||||
|
setResult(RESULT_CANCELED)
|
||||||
|
onBackPressed()
|
||||||
|
}
|
||||||
|
R.id.crypto_select -> selectFolder()
|
||||||
|
else -> return super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
private fun selectFolder() {
|
||||||
menuInflater.inflate(R.menu.pgp_handler_select_folder, menu)
|
intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
|
||||||
return true
|
setResult(RESULT_OK, intent)
|
||||||
}
|
finish()
|
||||||
|
}
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
when (item.itemId) {
|
|
||||||
android.R.id.home -> {
|
|
||||||
setResult(RESULT_CANCELED)
|
|
||||||
onBackPressed()
|
|
||||||
}
|
|
||||||
R.id.crypto_select -> selectFolder()
|
|
||||||
else -> return super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun selectFolder() {
|
|
||||||
intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
|
|
||||||
setResult(RESULT_OK, intent)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,56 +26,51 @@ import me.zhanghai.android.fastscroll.FastScrollerBuilder
|
||||||
|
|
||||||
class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
|
class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
|
|
||||||
private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
|
private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
|
||||||
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
|
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
|
||||||
private lateinit var listener: OnFragmentInteractionListener
|
private lateinit var listener: OnFragmentInteractionListener
|
||||||
|
|
||||||
private val model: SearchableRepositoryViewModel by activityViewModels()
|
private val model: SearchableRepositoryViewModel by activityViewModels()
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.fab.hide()
|
binding.fab.hide()
|
||||||
recyclerAdapter = PasswordItemRecyclerAdapter()
|
recyclerAdapter = PasswordItemRecyclerAdapter().onItemClicked { _, item -> listener.onFragmentInteraction(item) }
|
||||||
.onItemClicked { _, item ->
|
binding.passRecycler.apply {
|
||||||
listener.onFragmentInteraction(item)
|
layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
itemAnimator = null
|
||||||
|
adapter = recyclerAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
FastScrollerBuilder(binding.passRecycler).build()
|
||||||
|
registerForContextMenu(binding.passRecycler)
|
||||||
|
|
||||||
|
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
|
||||||
|
model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
|
||||||
|
model.searchResult.observe(viewLifecycleOwner) { result -> recyclerAdapter.submitList(result.passwordItems) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
runCatching {
|
||||||
|
listener =
|
||||||
|
object : OnFragmentInteractionListener {
|
||||||
|
override fun onFragmentInteraction(item: PasswordItem) {
|
||||||
|
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
||||||
|
model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
|
||||||
|
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
}
|
}
|
||||||
binding.passRecycler.apply {
|
}
|
||||||
layoutManager = LinearLayoutManager(requireContext())
|
|
||||||
itemAnimator = null
|
|
||||||
adapter = recyclerAdapter
|
|
||||||
}
|
|
||||||
|
|
||||||
FastScrollerBuilder(binding.passRecycler).build()
|
|
||||||
registerForContextMenu(binding.passRecycler)
|
|
||||||
|
|
||||||
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
|
|
||||||
model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
|
|
||||||
model.searchResult.observe(viewLifecycleOwner) { result ->
|
|
||||||
recyclerAdapter.submitList(result.passwordItems)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onFailure { throw ClassCastException("$context must implement OnFragmentInteractionListener") }
|
||||||
|
}
|
||||||
|
|
||||||
override fun onAttach(context: Context) {
|
val currentDir: File
|
||||||
super.onAttach(context)
|
get() = model.currentDir.value!!
|
||||||
runCatching {
|
|
||||||
listener = object : OnFragmentInteractionListener {
|
|
||||||
override fun onFragmentInteraction(item: PasswordItem) {
|
|
||||||
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
|
||||||
model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
|
|
||||||
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
throw ClassCastException("$context must implement OnFragmentInteractionListener")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentDir: File
|
interface OnFragmentInteractionListener {
|
||||||
get() = model.currentDir.value!!
|
|
||||||
|
|
||||||
interface OnFragmentInteractionListener {
|
fun onFragmentInteraction(item: PasswordItem)
|
||||||
|
}
|
||||||
fun onFragmentInteraction(item: PasswordItem)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,132 +33,130 @@ import net.schmizz.sshj.transport.TransportException
|
||||||
import net.schmizz.sshj.userauth.UserAuthException
|
import net.schmizz.sshj.userauth.UserAuthException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related
|
* Abstract [AppCompatActivity] that holds some information that is commonly shared across
|
||||||
* tasks and makes sense to be held here.
|
* git-related tasks and makes sense to be held here.
|
||||||
*/
|
*/
|
||||||
abstract class BaseGitActivity : ContinuationContainerActivity() {
|
abstract class BaseGitActivity : ContinuationContainerActivity() {
|
||||||
|
|
||||||
/**
|
/** Enum of possible Git operations than can be run through [launchGitOperation]. */
|
||||||
* Enum of possible Git operations than can be run through [launchGitOperation].
|
enum class GitOp {
|
||||||
*/
|
BREAK_OUT_OF_DETACHED,
|
||||||
enum class GitOp {
|
CLONE,
|
||||||
|
PULL,
|
||||||
|
PUSH,
|
||||||
|
RESET,
|
||||||
|
SYNC,
|
||||||
|
}
|
||||||
|
|
||||||
BREAK_OUT_OF_DETACHED,
|
/**
|
||||||
CLONE,
|
* Attempt to launch the requested Git operation.
|
||||||
PULL,
|
* @param operation The type of git operation to launch
|
||||||
PUSH,
|
*/
|
||||||
RESET,
|
suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> {
|
||||||
SYNC,
|
if (GitSettings.url == null) {
|
||||||
|
return Err(IllegalStateException("Git url is not set!"))
|
||||||
}
|
}
|
||||||
|
if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) {
|
||||||
|
// If the server does not support multiple SSH channels per connection, we cannot run
|
||||||
|
// a sync operation without reconnecting and thus break sync into its two parts.
|
||||||
|
return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) }
|
||||||
|
}
|
||||||
|
val op =
|
||||||
|
when (operation) {
|
||||||
|
GitOp.CLONE -> CloneOperation(this, GitSettings.url!!)
|
||||||
|
GitOp.PULL -> PullOperation(this, GitSettings.rebaseOnPull)
|
||||||
|
GitOp.PUSH -> PushOperation(this)
|
||||||
|
GitOp.SYNC -> SyncOperation(this, GitSettings.rebaseOnPull)
|
||||||
|
GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this)
|
||||||
|
GitOp.RESET -> ResetToRemoteOperation(this)
|
||||||
|
}
|
||||||
|
return op.executeAfterAuthentication(GitSettings.authMode).mapError(::transformGitError)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) {
|
||||||
* Attempt to launch the requested Git operation.
|
finish()
|
||||||
* @param operation The type of git operation to launch
|
}
|
||||||
*/
|
|
||||||
suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> {
|
|
||||||
if (GitSettings.url == null) {
|
|
||||||
return Err(IllegalStateException("Git url is not set!"))
|
|
||||||
}
|
|
||||||
if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) {
|
|
||||||
// If the server does not support multiple SSH channels per connection, we cannot run
|
|
||||||
// a sync operation without reconnecting and thus break sync into its two parts.
|
|
||||||
return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) }
|
|
||||||
}
|
|
||||||
val op = when (operation) {
|
|
||||||
GitOp.CLONE -> CloneOperation(this, GitSettings.url!!)
|
|
||||||
GitOp.PULL -> PullOperation(this, GitSettings.rebaseOnPull)
|
|
||||||
GitOp.PUSH -> PushOperation(this)
|
|
||||||
GitOp.SYNC -> SyncOperation(this, GitSettings.rebaseOnPull)
|
|
||||||
GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this)
|
|
||||||
GitOp.RESET -> ResetToRemoteOperation(this)
|
|
||||||
}
|
|
||||||
return op.executeAfterAuthentication(GitSettings.authMode).mapError(::transformGitError)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) {
|
suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
|
||||||
finish()
|
val error = rootCauseException(err)
|
||||||
}
|
if (!isExplicitlyUserInitiatedError(error)) {
|
||||||
|
getEncryptedGitPrefs().edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
|
||||||
suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
|
sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
|
||||||
val error = rootCauseException(err)
|
d(error)
|
||||||
if (!isExplicitlyUserInitiatedError(error)) {
|
withContext(Dispatchers.Main) {
|
||||||
getEncryptedGitPrefs().edit {
|
MaterialAlertDialogBuilder(this@BaseGitActivity).run {
|
||||||
remove(PreferenceKeys.HTTPS_PASSWORD)
|
setTitle(resources.getString(R.string.jgit_error_dialog_title))
|
||||||
}
|
setMessage(ErrorMessages[error])
|
||||||
sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
|
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
|
||||||
d(error)
|
setOnDismissListener { onPromptDone() }
|
||||||
withContext(Dispatchers.Main) {
|
show()
|
||||||
MaterialAlertDialogBuilder(this@BaseGitActivity).run {
|
|
||||||
setTitle(resources.getString(R.string.jgit_error_dialog_title))
|
|
||||||
setMessage(ErrorMessages[error])
|
|
||||||
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
|
|
||||||
setOnDismissListener {
|
|
||||||
onPromptDone()
|
|
||||||
}
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onPromptDone()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onPromptDone()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Takes the result of [launchGitOperation] and applies any necessary transformations
|
* Takes the result of [launchGitOperation] and applies any necessary transformations on the
|
||||||
* on the [throwable] returned from it
|
* [throwable] returned from it
|
||||||
*/
|
*/
|
||||||
private fun transformGitError(throwable: Throwable): Throwable {
|
private fun transformGitError(throwable: Throwable): Throwable {
|
||||||
val err = rootCauseException(throwable)
|
val err = rootCauseException(throwable)
|
||||||
return when {
|
return when {
|
||||||
err.message?.contains("cannot open additional channels") == true -> {
|
err.message?.contains("cannot open additional channels") == true -> {
|
||||||
GitSettings.useMultiplexing = false
|
GitSettings.useMultiplexing = false
|
||||||
SSHException(DisconnectReason.TOO_MANY_CONNECTIONS, "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used.")
|
SSHException(
|
||||||
}
|
DisconnectReason.TOO_MANY_CONNECTIONS,
|
||||||
err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> {
|
"The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used."
|
||||||
IllegalStateException("Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings")
|
)
|
||||||
}
|
}
|
||||||
err is TransportException && err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> {
|
err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> {
|
||||||
SSHException(DisconnectReason.HOST_KEY_NOT_VERIFIABLE,
|
IllegalStateException(
|
||||||
"WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."
|
"Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
err is TransportException && err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> {
|
||||||
err
|
SSHException(
|
||||||
}
|
DisconnectReason.HOST_KEY_NOT_VERIFIABLE,
|
||||||
}
|
"WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a given [Throwable] is the result of an error caused by the user cancelling the
|
* Check if a given [Throwable] is the result of an error caused by the user cancelling the
|
||||||
* operation.
|
* operation.
|
||||||
*/
|
*/
|
||||||
private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean {
|
private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean {
|
||||||
var cause: Throwable? = throwable
|
var cause: Throwable? = throwable
|
||||||
while (cause != null) {
|
while (cause != null) {
|
||||||
if (cause is SSHException &&
|
if (cause is SSHException && cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) return true
|
||||||
cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER)
|
cause = cause.cause
|
||||||
return true
|
|
||||||
cause = cause.cause
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no
|
* Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no
|
||||||
* longer found.
|
* longer found.
|
||||||
*/
|
*/
|
||||||
private fun rootCauseException(throwable: Throwable): Throwable {
|
private fun rootCauseException(throwable: Throwable): Throwable {
|
||||||
var rootCause = throwable
|
var rootCause = throwable
|
||||||
// JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ exceptions.
|
// JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ
|
||||||
// Also, SSHJ's UserAuthException about exhausting available authentication methods hides
|
// exceptions.
|
||||||
// more useful exceptions.
|
// Also, SSHJ's UserAuthException about exhausting available authentication methods hides
|
||||||
while ((rootCause is org.eclipse.jgit.errors.TransportException ||
|
// more useful exceptions.
|
||||||
rootCause is org.eclipse.jgit.api.errors.TransportException ||
|
while ((rootCause is org.eclipse.jgit.errors.TransportException ||
|
||||||
rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException ||
|
rootCause is org.eclipse.jgit.api.errors.TransportException ||
|
||||||
(rootCause is UserAuthException &&
|
rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException ||
|
||||||
rootCause.message == "Exhausted available authentication methods"))) {
|
(rootCause is UserAuthException && rootCause.message == "Exhausted available authentication methods"))) {
|
||||||
rootCause = rootCause.cause ?: break
|
rootCause = rootCause.cause ?: break
|
||||||
}
|
|
||||||
return rootCause
|
|
||||||
}
|
}
|
||||||
|
return rootCause
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,122 +33,113 @@ import org.eclipse.jgit.lib.RepositoryState
|
||||||
|
|
||||||
class GitConfigActivity : BaseGitActivity() {
|
class GitConfigActivity : BaseGitActivity() {
|
||||||
|
|
||||||
private val binding by viewBinding(ActivityGitConfigBinding::inflate)
|
private val binding by viewBinding(ActivityGitConfigBinding::inflate)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
if (GitSettings.authorName.isEmpty())
|
if (GitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus()
|
||||||
binding.gitUserName.requestFocus()
|
else binding.gitUserName.setText(GitSettings.authorName)
|
||||||
else
|
binding.gitUserEmail.setText(GitSettings.authorEmail)
|
||||||
binding.gitUserName.setText(GitSettings.authorName)
|
setupTools()
|
||||||
binding.gitUserEmail.setText(GitSettings.authorEmail)
|
binding.saveButton.setOnClickListener {
|
||||||
setupTools()
|
val email = binding.gitUserEmail.text.toString().trim()
|
||||||
binding.saveButton.setOnClickListener {
|
val name = binding.gitUserName.text.toString().trim()
|
||||||
val email = binding.gitUserEmail.text.toString().trim()
|
if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) {
|
||||||
val name = binding.gitUserName.text.toString().trim()
|
MaterialAlertDialogBuilder(this)
|
||||||
if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) {
|
.setMessage(getString(R.string.invalid_email_dialog_text))
|
||||||
MaterialAlertDialogBuilder(this)
|
.setPositiveButton(getString(R.string.dialog_ok), null)
|
||||||
.setMessage(getString(R.string.invalid_email_dialog_text))
|
.show()
|
||||||
.setPositiveButton(getString(R.string.dialog_ok), null)
|
} else {
|
||||||
.show()
|
GitSettings.authorEmail = email
|
||||||
} else {
|
GitSettings.authorName = name
|
||||||
GitSettings.authorEmail = email
|
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
|
||||||
GitSettings.authorName = name
|
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
||||||
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
|
}
|
||||||
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
android.R.id.home -> {
|
android.R.id.home -> {
|
||||||
onBackPressed()
|
onBackPressed()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** Sets up the UI components of the tools section. */
|
||||||
* Sets up the UI components of the tools section.
|
private fun setupTools() {
|
||||||
*/
|
val repo = PasswordRepository.getRepository(null)
|
||||||
private fun setupTools() {
|
if (repo != null) {
|
||||||
val repo = PasswordRepository.getRepository(null)
|
binding.gitHeadStatus.text = headStatusMsg(repo)
|
||||||
if (repo != null) {
|
// enable the abort button only if we're rebasing or merging
|
||||||
binding.gitHeadStatus.text = headStatusMsg(repo)
|
val needsAbort = repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
|
||||||
// enable the abort button only if we're rebasing or merging
|
binding.gitAbortRebase.isEnabled = needsAbort
|
||||||
val needsAbort = repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
|
binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
|
||||||
binding.gitAbortRebase.isEnabled = needsAbort
|
}
|
||||||
binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
|
binding.gitLog.setOnClickListener {
|
||||||
}
|
runCatching { startActivity(Intent(this, GitLogActivity::class.java)) }.onFailure { ex ->
|
||||||
binding.gitLog.setOnClickListener {
|
e(ex) { "Failed to start GitLogActivity" }
|
||||||
runCatching {
|
}
|
||||||
startActivity(Intent(this, GitLogActivity::class.java))
|
}
|
||||||
}.onFailure { ex ->
|
binding.gitAbortRebase.setOnClickListener {
|
||||||
e(ex) { "Failed to start GitLogActivity" }
|
lifecycleScope.launch {
|
||||||
}
|
launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED)
|
||||||
}
|
.fold(
|
||||||
binding.gitAbortRebase.setOnClickListener {
|
success = {
|
||||||
lifecycleScope.launch {
|
MaterialAlertDialogBuilder(this@GitConfigActivity).run {
|
||||||
launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED).fold(
|
setTitle(resources.getString(R.string.git_abort_and_push_title))
|
||||||
success = {
|
setMessage(
|
||||||
MaterialAlertDialogBuilder(this@GitConfigActivity).run {
|
resources.getString(
|
||||||
setTitle(resources.getString(R.string.git_abort_and_push_title))
|
R.string.git_break_out_of_detached_success,
|
||||||
setMessage(resources.getString(
|
GitSettings.branch,
|
||||||
R.string.git_break_out_of_detached_success,
|
"conflicting-${GitSettings.branch}-...",
|
||||||
GitSettings.branch,
|
)
|
||||||
"conflicting-${GitSettings.branch}-...",
|
|
||||||
))
|
|
||||||
setOnDismissListener { finish() }
|
|
||||||
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
failure = { err ->
|
|
||||||
promptOnErrorHandler(err) {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
setOnDismissListener { finish() }
|
||||||
}
|
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
|
||||||
binding.gitResetToRemote.setOnClickListener {
|
show()
|
||||||
lifecycleScope.launch {
|
}
|
||||||
launchGitOperation(GitOp.RESET).fold(
|
},
|
||||||
success = ::finishOnSuccessHandler,
|
failure = { err -> promptOnErrorHandler(err) { finish() } },
|
||||||
failure = { err ->
|
)
|
||||||
promptOnErrorHandler(err) {
|
}
|
||||||
finish()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
binding.gitResetToRemote.setOnClickListener {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
launchGitOperation(GitOp.RESET)
|
||||||
|
.fold(
|
||||||
|
success = ::finishOnSuccessHandler,
|
||||||
|
failure = { err -> promptOnErrorHandler(err) { finish() } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a user-friendly message about the current state of HEAD.
|
* Returns a user-friendly message about the current state of HEAD.
|
||||||
*
|
*
|
||||||
* The state is recognized to be either pointing to a branch or detached.
|
* The state is recognized to be either pointing to a branch or detached.
|
||||||
*/
|
*/
|
||||||
private fun headStatusMsg(repo: Repository): String {
|
private fun headStatusMsg(repo: Repository): String {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val headRef = repo.getRef(Constants.HEAD)
|
val headRef = repo.getRef(Constants.HEAD)
|
||||||
if (headRef.isSymbolic) {
|
if (headRef.isSymbolic) {
|
||||||
val branchName = headRef.target.name
|
val branchName = headRef.target.name
|
||||||
val shortBranchName = Repository.shortenRefName(branchName)
|
val shortBranchName = Repository.shortenRefName(branchName)
|
||||||
getString(R.string.git_head_on_branch, shortBranchName)
|
getString(R.string.git_head_on_branch, shortBranchName)
|
||||||
} else {
|
} else {
|
||||||
val commitHash = headRef.objectId.abbreviate(8).name()
|
val commitHash = headRef.objectId.abbreviate(8).name()
|
||||||
getString(R.string.git_head_detached, commitHash)
|
getString(R.string.git_head_detached, commitHash)
|
||||||
}
|
}
|
||||||
}.getOrElse { ex ->
|
|
||||||
e(ex) { "Error getting HEAD reference" }
|
|
||||||
getString(R.string.git_head_missing)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.getOrElse { ex ->
|
||||||
|
e(ex) { "Error getting HEAD reference" }
|
||||||
|
getString(R.string.git_head_missing)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,233 +41,226 @@ import kotlinx.coroutines.withContext
|
||||||
*/
|
*/
|
||||||
class GitServerConfigActivity : BaseGitActivity() {
|
class GitServerConfigActivity : BaseGitActivity() {
|
||||||
|
|
||||||
private val binding by viewBinding(ActivityGitCloneBinding::inflate)
|
private val binding by viewBinding(ActivityGitCloneBinding::inflate)
|
||||||
|
|
||||||
private lateinit var newAuthMode: AuthMode
|
private lateinit var newAuthMode: AuthMode
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val isClone = intent?.extras?.getBoolean("cloning") ?: false
|
val isClone = intent?.extras?.getBoolean("cloning") ?: false
|
||||||
if (isClone) {
|
if (isClone) {
|
||||||
binding.saveButton.text = getString(R.string.clone_button)
|
binding.saveButton.text = getString(R.string.clone_button)
|
||||||
}
|
}
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
newAuthMode = GitSettings.authMode
|
newAuthMode = GitSettings.authMode
|
||||||
|
|
||||||
binding.authModeGroup.apply {
|
binding.authModeGroup.apply {
|
||||||
when (newAuthMode) {
|
when (newAuthMode) {
|
||||||
AuthMode.SshKey -> check(binding.authModeSshKey.id)
|
AuthMode.SshKey -> check(binding.authModeSshKey.id)
|
||||||
AuthMode.Password -> check(binding.authModePassword.id)
|
AuthMode.Password -> check(binding.authModePassword.id)
|
||||||
AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id)
|
AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id)
|
||||||
AuthMode.None -> check(View.NO_ID)
|
AuthMode.None -> check(View.NO_ID)
|
||||||
}
|
}
|
||||||
setOnCheckedChangeListener { _, checkedId ->
|
setOnCheckedChangeListener { _, checkedId ->
|
||||||
when (checkedId) {
|
when (checkedId) {
|
||||||
binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
|
binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
|
||||||
binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
|
binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
|
||||||
binding.authModePassword.id -> newAuthMode = AuthMode.Password
|
binding.authModePassword.id -> newAuthMode = AuthMode.Password
|
||||||
View.NO_ID -> newAuthMode = AuthMode.None
|
View.NO_ID -> newAuthMode = AuthMode.None
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.serverUrl.setText(GitSettings.url.also {
|
|
||||||
if (it.isNullOrEmpty()) return@also
|
|
||||||
setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
|
|
||||||
})
|
|
||||||
binding.serverBranch.setText(GitSettings.branch)
|
|
||||||
|
|
||||||
binding.serverUrl.doOnTextChanged { text, _, _, _ ->
|
|
||||||
if (text.isNullOrEmpty()) return@doOnTextChanged
|
|
||||||
setAuthModes(text.startsWith("http://") || text.startsWith("https://"))
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.clearHostKeyButton.isVisible = GitSettings.hasSavedHostKey()
|
|
||||||
binding.clearHostKeyButton.setOnClickListener {
|
|
||||||
GitSettings.clearSavedHostKey()
|
|
||||||
Snackbar.make(binding.root, getString(R.string.clear_saved_host_key_success), Snackbar.LENGTH_LONG).show()
|
|
||||||
it.isVisible = false
|
|
||||||
}
|
|
||||||
binding.saveButton.setOnClickListener {
|
|
||||||
val newUrl = binding.serverUrl.text.toString().trim()
|
|
||||||
// If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://`
|
|
||||||
// in the beginning will cause the port to be seen as part of the path. Let users know
|
|
||||||
// about it and offer a quickfix.
|
|
||||||
if (newUrl.contains(PORT_REGEX)) {
|
|
||||||
if (newUrl.startsWith("https://")) {
|
|
||||||
BasicBottomSheet.Builder(this)
|
|
||||||
.setTitleRes(R.string.https_scheme_with_port_title)
|
|
||||||
.setMessageRes(R.string.https_scheme_with_port_message)
|
|
||||||
.setPositiveButtonClickListener {
|
|
||||||
binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/"))
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
|
||||||
return@setOnClickListener
|
|
||||||
} else if (!newUrl.startsWith("ssh://")) {
|
|
||||||
BasicBottomSheet.Builder(this)
|
|
||||||
.setTitleRes(R.string.ssh_scheme_needed_title)
|
|
||||||
.setMessageRes(R.string.ssh_scheme_needed_message)
|
|
||||||
.setPositiveButtonClickListener {
|
|
||||||
@Suppress("SetTextI18n")
|
|
||||||
binding.serverUrl.setText("ssh://$newUrl")
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
|
||||||
return@setOnClickListener
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when (val updateResult = GitSettings.updateConnectionSettingsIfValid(
|
|
||||||
newAuthMode = newAuthMode,
|
|
||||||
newUrl = binding.serverUrl.text.toString().trim(),
|
|
||||||
newBranch = binding.serverBranch.text.toString().trim())) {
|
|
||||||
GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> {
|
|
||||||
Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
|
|
||||||
is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> {
|
|
||||||
when (updateResult.newProtocol) {
|
|
||||||
Protocol.Https ->
|
|
||||||
BasicBottomSheet.Builder(this)
|
|
||||||
.setTitleRes(R.string.ssh_scheme_needed_title)
|
|
||||||
.setMessageRes(R.string.git_server_config_save_missing_username_https)
|
|
||||||
.setPositiveButtonClickListener {
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
.show(supportFragmentManager, "HTTPS_MISSING_USERNAME")
|
|
||||||
Protocol.Ssh ->
|
|
||||||
BasicBottomSheet.Builder(this)
|
|
||||||
.setTitleRes(R.string.ssh_scheme_needed_title)
|
|
||||||
.setMessageRes(R.string.git_server_config_save_missing_username_ssh)
|
|
||||||
.setPositiveButtonClickListener {
|
|
||||||
}
|
|
||||||
.build()
|
|
||||||
.show(supportFragmentManager, "SSH_MISSING_USERNAME")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
GitSettings.UpdateConnectionSettingsResult.Valid -> {
|
|
||||||
if (isClone && PasswordRepository.getRepository(null) == null)
|
|
||||||
PasswordRepository.initialize()
|
|
||||||
if (!isClone) {
|
|
||||||
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
|
|
||||||
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
|
||||||
} else {
|
|
||||||
cloneRepository()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> {
|
|
||||||
val message = getString(
|
|
||||||
R.string.git_server_config_save_auth_mode_mismatch,
|
|
||||||
updateResult.newProtocol,
|
|
||||||
updateResult.validModes.joinToString(", "),
|
|
||||||
)
|
|
||||||
Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
binding.serverUrl.setText(
|
||||||
return when (item.itemId) {
|
GitSettings.url.also {
|
||||||
android.R.id.home -> {
|
if (it.isNullOrEmpty()) return@also
|
||||||
onBackPressed()
|
setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
|
||||||
true
|
}
|
||||||
}
|
)
|
||||||
else -> super.onOptionsItemSelected(item)
|
binding.serverBranch.setText(GitSettings.branch)
|
||||||
}
|
|
||||||
|
binding.serverUrl.doOnTextChanged { text, _, _, _ ->
|
||||||
|
if (text.isNullOrEmpty()) return@doOnTextChanged
|
||||||
|
setAuthModes(text.startsWith("http://") || text.startsWith("https://"))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setAuthModes(isHttps: Boolean) = with(binding) {
|
binding.clearHostKeyButton.isVisible = GitSettings.hasSavedHostKey()
|
||||||
if (isHttps) {
|
binding.clearHostKeyButton.setOnClickListener {
|
||||||
authModeSshKey.isVisible = false
|
GitSettings.clearSavedHostKey()
|
||||||
authModeOpenKeychain.isVisible = false
|
Snackbar.make(binding.root, getString(R.string.clear_saved_host_key_success), Snackbar.LENGTH_LONG).show()
|
||||||
authModePassword.isVisible = true
|
it.isVisible = false
|
||||||
if (authModeGroup.checkedChipId != authModePassword.id)
|
}
|
||||||
authModeGroup.check(View.NO_ID)
|
binding.saveButton.setOnClickListener {
|
||||||
} else {
|
val newUrl = binding.serverUrl.text.toString().trim()
|
||||||
authModeSshKey.isVisible = true
|
// If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://`
|
||||||
authModeOpenKeychain.isVisible = true
|
// in the beginning will cause the port to be seen as part of the path. Let users know
|
||||||
authModePassword.isVisible = true
|
// about it and offer a quickfix.
|
||||||
if (authModeGroup.checkedChipId == View.NO_ID)
|
if (newUrl.contains(PORT_REGEX)) {
|
||||||
authModeGroup.check(authModeSshKey.id)
|
if (newUrl.startsWith("https://")) {
|
||||||
|
BasicBottomSheet.Builder(this)
|
||||||
|
.setTitleRes(R.string.https_scheme_with_port_title)
|
||||||
|
.setMessageRes(R.string.https_scheme_with_port_message)
|
||||||
|
.setPositiveButtonClickListener { binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) }
|
||||||
|
.build()
|
||||||
|
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
||||||
|
return@setOnClickListener
|
||||||
|
} else if (!newUrl.startsWith("ssh://")) {
|
||||||
|
BasicBottomSheet.Builder(this)
|
||||||
|
.setTitleRes(R.string.ssh_scheme_needed_title)
|
||||||
|
.setMessageRes(R.string.ssh_scheme_needed_message)
|
||||||
|
.setPositiveButtonClickListener { @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl") }
|
||||||
|
.build()
|
||||||
|
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
||||||
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
when (val updateResult =
|
||||||
|
GitSettings.updateConnectionSettingsIfValid(
|
||||||
|
newAuthMode = newAuthMode,
|
||||||
|
newUrl = binding.serverUrl.text.toString().trim(),
|
||||||
|
newBranch = binding.serverBranch.text.toString().trim()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> {
|
||||||
|
Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> {
|
||||||
|
when (updateResult.newProtocol) {
|
||||||
|
Protocol.Https ->
|
||||||
|
BasicBottomSheet.Builder(this)
|
||||||
|
.setTitleRes(R.string.ssh_scheme_needed_title)
|
||||||
|
.setMessageRes(R.string.git_server_config_save_missing_username_https)
|
||||||
|
.setPositiveButtonClickListener {}
|
||||||
|
.build()
|
||||||
|
.show(supportFragmentManager, "HTTPS_MISSING_USERNAME")
|
||||||
|
Protocol.Ssh ->
|
||||||
|
BasicBottomSheet.Builder(this)
|
||||||
|
.setTitleRes(R.string.ssh_scheme_needed_title)
|
||||||
|
.setMessageRes(R.string.git_server_config_save_missing_username_ssh)
|
||||||
|
.setPositiveButtonClickListener {}
|
||||||
|
.build()
|
||||||
|
.show(supportFragmentManager, "SSH_MISSING_USERNAME")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GitSettings.UpdateConnectionSettingsResult.Valid -> {
|
||||||
|
if (isClone && PasswordRepository.getRepository(null) == null) PasswordRepository.initialize()
|
||||||
|
if (!isClone) {
|
||||||
|
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
||||||
|
} else {
|
||||||
|
cloneRepository()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> {
|
||||||
|
val message =
|
||||||
|
getString(
|
||||||
|
R.string.git_server_config_save_auth_mode_mismatch,
|
||||||
|
updateResult.newProtocol,
|
||||||
|
updateResult.validModes.joinToString(", "),
|
||||||
|
)
|
||||||
|
Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
return when (item.itemId) {
|
||||||
|
android.R.id.home -> {
|
||||||
|
onBackPressed()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setAuthModes(isHttps: Boolean) =
|
||||||
|
with(binding) {
|
||||||
|
if (isHttps) {
|
||||||
|
authModeSshKey.isVisible = false
|
||||||
|
authModeOpenKeychain.isVisible = false
|
||||||
|
authModePassword.isVisible = true
|
||||||
|
if (authModeGroup.checkedChipId != authModePassword.id) authModeGroup.check(View.NO_ID)
|
||||||
|
} else {
|
||||||
|
authModeSshKey.isVisible = true
|
||||||
|
authModeOpenKeychain.isVisible = true
|
||||||
|
authModePassword.isVisible = true
|
||||||
|
if (authModeGroup.checkedChipId == View.NO_ID) authModeGroup.check(authModeSshKey.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Clones the repository, the directory exists, deletes it */
|
||||||
* Clones the repository, the directory exists, deletes it
|
private fun cloneRepository() {
|
||||||
*/
|
val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory())
|
||||||
private fun cloneRepository() {
|
val localDirFiles = localDir.listFiles() ?: emptyArray()
|
||||||
val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory())
|
// Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
|
||||||
val localDirFiles = localDir.listFiles() ?: emptyArray()
|
if (localDir.exists() && localDirFiles.isNotEmpty() && !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")
|
||||||
// Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
|
) {
|
||||||
if (localDir.exists() && localDirFiles.isNotEmpty() &&
|
MaterialAlertDialogBuilder(this)
|
||||||
!(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) {
|
.setTitle(R.string.dialog_delete_title)
|
||||||
MaterialAlertDialogBuilder(this)
|
.setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
|
||||||
.setTitle(R.string.dialog_delete_title)
|
.setCancelable(false)
|
||||||
.setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
|
.setPositiveButton(R.string.dialog_delete) { dialog, _ ->
|
||||||
.setCancelable(false)
|
runCatching {
|
||||||
.setPositiveButton(R.string.dialog_delete) { dialog, _ ->
|
|
||||||
runCatching {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
val snackbar = snackbar(message = getString(R.string.delete_directory_progress_text), length = Snackbar.LENGTH_INDEFINITE)
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
localDir.deleteRecursively()
|
|
||||||
}
|
|
||||||
snackbar.dismiss()
|
|
||||||
launchGitOperation(GitOp.CLONE).fold(
|
|
||||||
success = {
|
|
||||||
setResult(RESULT_OK)
|
|
||||||
finish()
|
|
||||||
},
|
|
||||||
failure = { err ->
|
|
||||||
promptOnErrorHandler(err) {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
e.printStackTrace()
|
|
||||||
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
|
|
||||||
}
|
|
||||||
dialog.cancel()
|
|
||||||
}
|
|
||||||
.setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ ->
|
|
||||||
dialog.cancel()
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
} else {
|
|
||||||
runCatching {
|
|
||||||
// Silently delete & replace the lone .git folder if it exists
|
|
||||||
if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
|
|
||||||
localDir.deleteRecursively()
|
|
||||||
}
|
|
||||||
}.onFailure { e ->
|
|
||||||
e(e)
|
|
||||||
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
|
|
||||||
}
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
launchGitOperation(GitOp.CLONE).fold(
|
val snackbar =
|
||||||
success = {
|
snackbar(
|
||||||
setResult(RESULT_OK)
|
message = getString(R.string.delete_directory_progress_text),
|
||||||
finish()
|
length = Snackbar.LENGTH_INDEFINITE
|
||||||
},
|
)
|
||||||
failure = { promptOnErrorHandler(it) },
|
withContext(Dispatchers.IO) { localDir.deleteRecursively() }
|
||||||
|
snackbar.dismiss()
|
||||||
|
launchGitOperation(GitOp.CLONE)
|
||||||
|
.fold(
|
||||||
|
success = {
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
failure = { err -> promptOnErrorHandler(err) { finish() } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.onFailure { e ->
|
||||||
|
e.printStackTrace()
|
||||||
companion object {
|
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
|
||||||
|
|
||||||
private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
|
|
||||||
|
|
||||||
fun createCloneIntent(context: Context): Intent {
|
|
||||||
return Intent(context, GitServerConfigActivity::class.java).apply {
|
|
||||||
putExtra("cloning", true)
|
|
||||||
}
|
}
|
||||||
|
dialog.cancel()
|
||||||
}
|
}
|
||||||
|
.setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() }
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
runCatching {
|
||||||
|
// Silently delete & replace the lone .git folder if it exists
|
||||||
|
if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
|
||||||
|
localDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
e(e)
|
||||||
|
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
|
||||||
|
}
|
||||||
|
lifecycleScope.launch {
|
||||||
|
launchGitOperation(GitOp.CLONE)
|
||||||
|
.fold(
|
||||||
|
success = {
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
failure = { promptOnErrorHandler(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
|
||||||
|
|
||||||
|
fun createCloneIntent(context: Context): Intent {
|
||||||
|
return Intent(context, GitServerConfigActivity::class.java).apply { putExtra("cloning", true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,30 +20,30 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
|
||||||
*/
|
*/
|
||||||
class GitLogActivity : BaseGitActivity() {
|
class GitLogActivity : BaseGitActivity() {
|
||||||
|
|
||||||
private val binding by viewBinding(ActivityGitLogBinding::inflate)
|
private val binding by viewBinding(ActivityGitLogBinding::inflate)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
createRecyclerView()
|
createRecyclerView()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
android.R.id.home -> {
|
android.R.id.home -> {
|
||||||
finish()
|
finish()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createRecyclerView() {
|
private fun createRecyclerView() {
|
||||||
binding.gitLogRecyclerView.apply {
|
binding.gitLogRecyclerView.apply {
|
||||||
setHasFixedSize(true)
|
setHasFixedSize(true)
|
||||||
addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
|
addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
|
||||||
adapter = GitLogAdapter()
|
adapter = GitLogAdapter()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,43 +16,42 @@ import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
private fun shortHash(hash: String): String {
|
private fun shortHash(hash: String): String {
|
||||||
return hash.substring(0 until 8)
|
return hash.substring(0 until 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stringFrom(date: Date): String {
|
private fun stringFrom(date: Date): String {
|
||||||
return DateFormat.getDateTimeInstance().format(date)
|
return DateFormat.getDateTimeInstance().format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @see GitLogActivity */
|
||||||
* @see GitLogActivity
|
|
||||||
*/
|
|
||||||
class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
|
class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
|
||||||
|
|
||||||
private val model = GitLogModel()
|
private val model = GitLogModel()
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
val inflater = LayoutInflater.from(parent.context)
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false)
|
val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false)
|
||||||
return ViewHolder(binding)
|
return ViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
|
||||||
|
val commit = model.get(position)
|
||||||
|
if (commit == null) {
|
||||||
|
e { "There is no git commit for view holder at position $position." }
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
viewHolder.bind(commit)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
|
override fun getItemCount() = model.size
|
||||||
val commit = model.get(position)
|
|
||||||
if (commit == null) {
|
|
||||||
e { "There is no git commit for view holder at position $position." }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewHolder.bind(commit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount() = model.size
|
class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
|
fun bind(commit: GitCommit) =
|
||||||
|
with(binding) {
|
||||||
fun bind(commit: GitCommit) = with(binding) {
|
gitLogRowMessage.text = commit.shortMessage
|
||||||
gitLogRowMessage.text = commit.shortMessage
|
gitLogRowHash.text = shortHash(commit.hash)
|
||||||
gitLogRowHash.text = shortHash(commit.hash)
|
gitLogRowTime.text = stringFrom(commit.time)
|
||||||
gitLogRowTime.text = stringFrom(commit.time)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,46 +18,46 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
|
|
||||||
class LaunchActivity : AppCompatActivity() {
|
class LaunchActivity : AppCompatActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val prefs = sharedPrefs
|
val prefs = sharedPrefs
|
||||||
if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) {
|
if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) {
|
||||||
BiometricAuthenticator.authenticate(this) {
|
BiometricAuthenticator.authenticate(this) {
|
||||||
when (it) {
|
when (it) {
|
||||||
is BiometricAuthenticator.Result.Success -> {
|
is BiometricAuthenticator.Result.Success -> {
|
||||||
startTargetActivity(false)
|
startTargetActivity(false)
|
||||||
}
|
}
|
||||||
is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
|
is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
|
||||||
prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) }
|
prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) }
|
||||||
startTargetActivity(false)
|
startTargetActivity(false)
|
||||||
}
|
}
|
||||||
is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> {
|
is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> {
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
startTargetActivity(true)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startTargetActivity(true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun startTargetActivity(noAuth: Boolean) {
|
private fun startTargetActivity(noAuth: Boolean) {
|
||||||
val intentToStart = if (intent.action == ACTION_DECRYPT_PASS)
|
val intentToStart =
|
||||||
Intent(this, DecryptActivity::class.java).apply {
|
if (intent.action == ACTION_DECRYPT_PASS)
|
||||||
putExtra("NAME", intent.getStringExtra("NAME"))
|
Intent(this, DecryptActivity::class.java).apply {
|
||||||
putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH"))
|
putExtra("NAME", intent.getStringExtra("NAME"))
|
||||||
putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH"))
|
putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH"))
|
||||||
putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L))
|
putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH"))
|
||||||
}
|
putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L))
|
||||||
else
|
}
|
||||||
Intent(this, PasswordStore::class.java)
|
else Intent(this, PasswordStore::class.java)
|
||||||
startActivity(intentToStart)
|
startActivity(intentToStart)
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L)
|
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val ACTION_DECRYPT_PASS = "DECRYPT_PASS"
|
const val ACTION_DECRYPT_PASS = "DECRYPT_PASS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,16 +11,16 @@ import dev.msfjarvis.aps.R
|
||||||
|
|
||||||
class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) {
|
class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
supportActionBar?.hide()
|
supportActionBar?.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBackPressed() {
|
override fun onBackPressed() {
|
||||||
if (supportFragmentManager.backStackEntryCount == 0) {
|
if (supportFragmentManager.backStackEntryCount == 0) {
|
||||||
finishAffinity()
|
finishAffinity()
|
||||||
} else {
|
} else {
|
||||||
super.onBackPressed()
|
super.onBackPressed()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,37 +22,34 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
|
|
||||||
class CloneFragment : Fragment(R.layout.fragment_clone) {
|
class CloneFragment : Fragment(R.layout.fragment_clone) {
|
||||||
|
|
||||||
private val binding by viewBinding(FragmentCloneBinding::bind)
|
private val binding by viewBinding(FragmentCloneBinding::bind)
|
||||||
|
|
||||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
||||||
|
|
||||||
private val cloneAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val cloneAction =
|
||||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
|
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||||
finish()
|
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
|
||||||
}
|
finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.cloneRemote.setOnClickListener {
|
binding.cloneRemote.setOnClickListener { cloneToHiddenDir() }
|
||||||
cloneToHiddenDir()
|
binding.createLocal.setOnClickListener {
|
||||||
}
|
parentFragmentManager.performTransactionWithBackStack(RepoLocationFragment.newInstance())
|
||||||
binding.createLocal.setOnClickListener {
|
|
||||||
parentFragmentManager.performTransactionWithBackStack(RepoLocationFragment.newInstance())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** Clones a remote Git repository to the app's private directory */
|
||||||
* Clones a remote Git repository to the app's private directory
|
private fun cloneToHiddenDir() {
|
||||||
*/
|
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) }
|
||||||
private fun cloneToHiddenDir() {
|
cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
|
||||||
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) }
|
}
|
||||||
cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun newInstance(): CloneFragment = CloneFragment()
|
fun newInstance(): CloneFragment = CloneFragment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,37 +30,37 @@ import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||||
|
|
||||||
class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
|
class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
|
||||||
|
|
||||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
||||||
private val binding by viewBinding(FragmentKeySelectionBinding::bind)
|
private val binding by viewBinding(FragmentKeySelectionBinding::bind)
|
||||||
|
|
||||||
private val gpgKeySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val gpgKeySelectAction =
|
||||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||||
lifecycleScope.launch {
|
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
||||||
withContext(Dispatchers.IO) {
|
lifecycleScope.launch {
|
||||||
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
|
withContext(Dispatchers.IO) {
|
||||||
gpgIdentifierFile.writeText((keyIds + "").joinToString("\n"))
|
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
|
||||||
}
|
gpgIdentifierFile.writeText((keyIds + "").joinToString("\n"))
|
||||||
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
|
|
||||||
requireActivity().commitChange(getString(
|
|
||||||
R.string.git_commit_gpg_id,
|
|
||||||
getString(R.string.app_name)
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
|
||||||
throw IllegalStateException("Failed to initialize repository state.")
|
requireActivity().commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
finish()
|
} else {
|
||||||
|
throw IllegalStateException("Failed to initialize repository state.")
|
||||||
|
}
|
||||||
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.selectKey.setOnClickListener { gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) }
|
binding.selectKey.setOnClickListener {
|
||||||
|
gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun newInstance() = KeySelectionFragment()
|
fun newInstance() = KeySelectionFragment()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,159 +35,155 @@ import java.io.File
|
||||||
|
|
||||||
class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
|
class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
|
||||||
|
|
||||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
||||||
private val directorySelectIntent by lazy(LazyThreadSafetyMode.NONE) { Intent(requireContext(), DirectorySelectionActivity::class.java) }
|
private val directorySelectIntent by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
private val binding by viewBinding(FragmentRepoLocationBinding::bind)
|
Intent(requireContext(), DirectorySelectionActivity::class.java)
|
||||||
private val sortOrder: PasswordSortOrder
|
}
|
||||||
get() = PasswordSortOrder.getSortOrder(settings)
|
private val binding by viewBinding(FragmentRepoLocationBinding::bind)
|
||||||
|
private val sortOrder: PasswordSortOrder
|
||||||
|
get() = PasswordSortOrder.getSortOrder(settings)
|
||||||
|
|
||||||
private val repositoryInitAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val repositoryInitAction =
|
||||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
initializeRepositoryInfo()
|
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val externalDirectorySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
|
||||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
|
||||||
if (checkExternalDirectory()) {
|
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
createRepository()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val externalDirPermGrantedAction = createPermGrantedAction {
|
|
||||||
externalDirectorySelectAction.launch(directorySelectIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val repositoryUsePermGrantedAction = createPermGrantedAction {
|
|
||||||
initializeRepositoryInfo()
|
initializeRepositoryInfo()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val repositoryChangePermGrantedAction = createPermGrantedAction {
|
private val externalDirectorySelectAction =
|
||||||
repositoryInitAction.launch(directorySelectIntent)
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
}
|
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||||
|
if (checkExternalDirectory()) {
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
finish()
|
||||||
super.onViewCreated(view, savedInstanceState)
|
|
||||||
binding.hidden.setOnClickListener {
|
|
||||||
createRepoInHiddenDir()
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.sdcard.setOnClickListener {
|
|
||||||
createRepoFromExternalDir()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes an empty repository in the app's private directory
|
|
||||||
*/
|
|
||||||
private fun createRepoInHiddenDir() {
|
|
||||||
settings.edit {
|
|
||||||
putBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
|
||||||
remove(PreferenceKeys.GIT_EXTERNAL_REPO)
|
|
||||||
}
|
|
||||||
initializeRepositoryInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes an empty repository in a selected directory if one does not already exist
|
|
||||||
*/
|
|
||||||
private fun createRepoFromExternalDir() {
|
|
||||||
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) }
|
|
||||||
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
|
||||||
if (externalRepo == null) {
|
|
||||||
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
|
||||||
externalDirPermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
|
||||||
} else {
|
|
||||||
// Unlikely we have storage permissions without user ever selecting a directory,
|
|
||||||
// but let's not assume.
|
|
||||||
externalDirectorySelectAction.launch(directorySelectIntent)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
MaterialAlertDialogBuilder(requireActivity())
|
createRepository()
|
||||||
.setTitle(resources.getString(R.string.directory_selected_title))
|
|
||||||
.setMessage(resources.getString(R.string.directory_selected_message, externalRepo))
|
|
||||||
.setPositiveButton(resources.getString(R.string.use)) { _, _ ->
|
|
||||||
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
|
||||||
repositoryUsePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
|
||||||
} else {
|
|
||||||
initializeRepositoryInfo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.setNegativeButton(resources.getString(R.string.change)) { _, _ ->
|
|
||||||
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
|
||||||
repositoryChangePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
|
||||||
} else {
|
|
||||||
repositoryInitAction.launch(directorySelectIntent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun checkExternalDirectory(): Boolean {
|
private val externalDirPermGrantedAction = createPermGrantedAction {
|
||||||
if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) &&
|
externalDirectorySelectAction.launch(directorySelectIntent)
|
||||||
settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null) {
|
}
|
||||||
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
|
||||||
val dir = externalRepoPath?.let { File(it) }
|
private val repositoryUsePermGrantedAction = createPermGrantedAction { initializeRepositoryInfo() }
|
||||||
if (dir != null && // The directory could be opened
|
|
||||||
dir.exists() && // The directory exists
|
private val repositoryChangePermGrantedAction = createPermGrantedAction {
|
||||||
dir.isDirectory && // The directory, is really a directory
|
repositoryInitAction.launch(directorySelectIntent)
|
||||||
dir.listFilesRecursively().isNotEmpty() && // The directory contains files
|
}
|
||||||
// The directory contains a non-zero number of password files
|
|
||||||
PasswordRepository.getPasswords(dir, PasswordRepository.getRepositoryDirectory(), sortOrder).isNotEmpty()
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
) {
|
super.onViewCreated(view, savedInstanceState)
|
||||||
PasswordRepository.closeRepository()
|
binding.hidden.setOnClickListener { createRepoInHiddenDir() }
|
||||||
return true
|
|
||||||
}
|
binding.sdcard.setOnClickListener { createRepoFromExternalDir() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initializes an empty repository in the app's private directory */
|
||||||
|
private fun createRepoInHiddenDir() {
|
||||||
|
settings.edit {
|
||||||
|
putBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
||||||
|
remove(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||||
|
}
|
||||||
|
initializeRepositoryInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initializes an empty repository in a selected directory if one does not already exist */
|
||||||
|
private fun createRepoFromExternalDir() {
|
||||||
|
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) }
|
||||||
|
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||||
|
if (externalRepo == null) {
|
||||||
|
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||||
|
externalDirPermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
} else {
|
||||||
|
// Unlikely we have storage permissions without user ever selecting a directory,
|
||||||
|
// but let's not assume.
|
||||||
|
externalDirectorySelectAction.launch(directorySelectIntent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MaterialAlertDialogBuilder(requireActivity())
|
||||||
|
.setTitle(resources.getString(R.string.directory_selected_title))
|
||||||
|
.setMessage(resources.getString(R.string.directory_selected_message, externalRepo))
|
||||||
|
.setPositiveButton(resources.getString(R.string.use)) { _, _ ->
|
||||||
|
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||||
|
repositoryUsePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
} else {
|
||||||
|
initializeRepositoryInfo()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
.setNegativeButton(resources.getString(R.string.change)) { _, _ ->
|
||||||
|
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||||
|
repositoryChangePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
} else {
|
||||||
|
repositoryInitAction.launch(directorySelectIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkExternalDirectory(): Boolean {
|
||||||
|
if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) &&
|
||||||
|
settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null
|
||||||
|
) {
|
||||||
|
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||||
|
val dir = externalRepoPath?.let { File(it) }
|
||||||
|
if (dir != null && // The directory could be opened
|
||||||
|
dir.exists() && // The directory exists
|
||||||
|
dir.isDirectory && // The directory, is really a directory
|
||||||
|
dir.listFilesRecursively().isNotEmpty() && // The directory contains files
|
||||||
|
// The directory contains a non-zero number of password files
|
||||||
|
PasswordRepository.getPasswords(dir, PasswordRepository.getRepositoryDirectory(), sortOrder).isNotEmpty()
|
||||||
|
) {
|
||||||
|
PasswordRepository.closeRepository()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRepository() {
|
||||||
|
val localDir = PasswordRepository.getRepositoryDirectory()
|
||||||
|
runCatching {
|
||||||
|
check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" }
|
||||||
|
PasswordRepository.createRepository(localDir)
|
||||||
|
if (!PasswordRepository.isInitialized) {
|
||||||
|
PasswordRepository.initialize()
|
||||||
|
}
|
||||||
|
parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance())
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
e(e)
|
||||||
|
if (!localDir.delete()) {
|
||||||
|
d { "Failed to delete local repository: $localDir" }
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeRepositoryInfo() {
|
||||||
|
val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
||||||
|
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||||
|
if (externalRepo && !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (externalRepo && externalRepoPath != null) {
|
||||||
|
if (checkExternalDirectory()) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createRepository()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createPermGrantedAction(block: () -> Unit) =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
block.invoke()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createRepository() {
|
companion object {
|
||||||
val localDir = PasswordRepository.getRepositoryDirectory()
|
|
||||||
runCatching {
|
|
||||||
check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" }
|
|
||||||
PasswordRepository.createRepository(localDir)
|
|
||||||
if (!PasswordRepository.isInitialized) {
|
|
||||||
PasswordRepository.initialize()
|
|
||||||
}
|
|
||||||
parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance())
|
|
||||||
}.onFailure { e ->
|
|
||||||
e(e)
|
|
||||||
if (!localDir.delete()) {
|
|
||||||
d { "Failed to delete local repository: $localDir" }
|
|
||||||
}
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initializeRepositoryInfo() {
|
fun newInstance(): RepoLocationFragment = RepoLocationFragment()
|
||||||
val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
}
|
||||||
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
|
||||||
if (externalRepo && !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (externalRepo && externalRepoPath != null) {
|
|
||||||
if (checkExternalDirectory()) {
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createRepository()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createPermGrantedAction(block: () -> Unit) =
|
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
|
||||||
if (granted) {
|
|
||||||
block.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun newInstance(): RepoLocationFragment = RepoLocationFragment()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,11 +20,13 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
class WelcomeFragment : Fragment(R.layout.fragment_welcome) {
|
class WelcomeFragment : Fragment(R.layout.fragment_welcome) {
|
||||||
|
|
||||||
private val binding by viewBinding(FragmentWelcomeBinding::bind)
|
private val binding by viewBinding(FragmentWelcomeBinding::bind)
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
binding.letsGo.setOnClickListener { parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) }
|
binding.letsGo.setOnClickListener {
|
||||||
binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), SettingsActivity::class.java)) }
|
parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance())
|
||||||
}
|
}
|
||||||
|
binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), SettingsActivity::class.java)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,296 +49,278 @@ import me.zhanghai.android.fastscroll.FastScrollerBuilder
|
||||||
|
|
||||||
class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
|
|
||||||
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
|
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
|
||||||
private lateinit var listener: OnFragmentInteractionListener
|
private lateinit var listener: OnFragmentInteractionListener
|
||||||
private lateinit var settings: SharedPreferences
|
private lateinit var settings: SharedPreferences
|
||||||
|
|
||||||
private var recyclerViewStateToRestore: Parcelable? = null
|
private var recyclerViewStateToRestore: Parcelable? = null
|
||||||
private var actionMode: ActionMode? = null
|
private var actionMode: ActionMode? = null
|
||||||
private var scrollTarget: File? = null
|
private var scrollTarget: File? = null
|
||||||
|
|
||||||
private val model: SearchableRepositoryViewModel by activityViewModels()
|
private val model: SearchableRepositoryViewModel by activityViewModels()
|
||||||
private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
|
private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
|
||||||
private val swipeResult = registerForActivityResult(StartActivityForResult()) {
|
private val swipeResult =
|
||||||
binding.swipeRefresher.isRefreshing = false
|
registerForActivityResult(StartActivityForResult()) {
|
||||||
|
binding.swipeRefresher.isRefreshing = false
|
||||||
|
requireStore().refreshPasswordList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentDir: File
|
||||||
|
get() = model.currentDir.value!!
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
settings = requireContext().sharedPrefs
|
||||||
|
initializePasswordList()
|
||||||
|
binding.fab.setOnClickListener { ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") }
|
||||||
|
childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
||||||
|
when (bundle.getString(ACTION_KEY)) {
|
||||||
|
ACTION_FOLDER -> requireStore().createFolder()
|
||||||
|
ACTION_PASSWORD -> requireStore().createPassword()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializePasswordList() {
|
||||||
|
val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
|
||||||
|
val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
|
||||||
|
binding.swipeRefresher.setOnRefreshListener {
|
||||||
|
if (!hasGitDir) {
|
||||||
requireStore().refreshPasswordList()
|
requireStore().refreshPasswordList()
|
||||||
|
binding.swipeRefresher.isRefreshing = false
|
||||||
|
} else if (!PasswordRepository.isGitRepo()) {
|
||||||
|
BasicBottomSheet.Builder(requireContext())
|
||||||
|
.setMessageRes(R.string.clone_git_repo)
|
||||||
|
.setPositiveButtonClickListener(getString(R.string.clone_button)) {
|
||||||
|
swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
.show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO")
|
||||||
|
binding.swipeRefresher.isRefreshing = false
|
||||||
|
} else {
|
||||||
|
// When authentication is set to AuthMode.None then the only git operation we can
|
||||||
|
// run is a pull, so automatically fallback to that.
|
||||||
|
val operationId =
|
||||||
|
when (GitSettings.authMode) {
|
||||||
|
AuthMode.None -> BaseGitActivity.GitOp.PULL
|
||||||
|
else -> BaseGitActivity.GitOp.SYNC
|
||||||
|
}
|
||||||
|
requireStore().apply {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
launchGitOperation(operationId)
|
||||||
|
.fold(
|
||||||
|
success = {
|
||||||
|
binding.swipeRefresher.isRefreshing = false
|
||||||
|
refreshPasswordList()
|
||||||
|
},
|
||||||
|
failure = { err -> promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentDir: File
|
recyclerAdapter =
|
||||||
get() = model.currentDir.value!!
|
PasswordItemRecyclerAdapter()
|
||||||
|
.onItemClicked { _, item -> listener.onFragmentInteraction(item) }
|
||||||
|
.onSelectionChanged { selection ->
|
||||||
|
// In order to not interfere with drag selection, we disable the
|
||||||
|
// SwipeRefreshLayout
|
||||||
|
// once an item is selected.
|
||||||
|
binding.swipeRefresher.isEnabled = selection.isEmpty
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
if (actionMode == null)
|
||||||
super.onViewCreated(view, savedInstanceState)
|
actionMode = requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged
|
||||||
settings = requireContext().sharedPrefs
|
|
||||||
initializePasswordList()
|
if (!selection.isEmpty) {
|
||||||
binding.fab.setOnClickListener {
|
actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size())
|
||||||
ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET")
|
actionMode!!.invalidate()
|
||||||
}
|
} else {
|
||||||
childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
actionMode!!.finish()
|
||||||
when (bundle.getString(ACTION_KEY)) {
|
}
|
||||||
ACTION_FOLDER -> requireStore().createFolder()
|
|
||||||
ACTION_PASSWORD -> requireStore().createPassword()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val recyclerView = binding.passRecycler
|
||||||
|
recyclerView.apply {
|
||||||
|
addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
|
||||||
|
layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
itemAnimator = OnOffItemAnimator()
|
||||||
|
adapter = recyclerAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initializePasswordList() {
|
FastScrollerBuilder(recyclerView).build()
|
||||||
val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
|
recyclerAdapter.makeSelectable(recyclerView)
|
||||||
val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
|
registerForContextMenu(recyclerView)
|
||||||
binding.swipeRefresher.setOnRefreshListener {
|
|
||||||
if (!hasGitDir) {
|
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
|
||||||
requireStore().refreshPasswordList()
|
model.navigateTo(File(path), pushPreviousLocation = false)
|
||||||
binding.swipeRefresher.isRefreshing = false
|
model.searchResult.observe(viewLifecycleOwner) { result ->
|
||||||
} else if (!PasswordRepository.isGitRepo()) {
|
// Only run animations when the new list is filtered, i.e., the user submitted a search,
|
||||||
BasicBottomSheet.Builder(requireContext())
|
// and not on folder navigations since the latter leads to too many removal animations.
|
||||||
.setMessageRes(R.string.clone_git_repo)
|
(recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
|
||||||
.setPositiveButtonClickListener(getString(R.string.clone_button)) {
|
recyclerAdapter.submitList(result.passwordItems) {
|
||||||
swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
|
when {
|
||||||
}
|
result.isFiltered -> {
|
||||||
.build()
|
// When the result is filtered, we always scroll to the top since that is
|
||||||
.show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO")
|
// where
|
||||||
binding.swipeRefresher.isRefreshing = false
|
// the best fuzzy match appears.
|
||||||
} else {
|
recyclerView.scrollToPosition(0)
|
||||||
// When authentication is set to AuthMode.None then the only git operation we can
|
}
|
||||||
// run is a pull, so automatically fallback to that.
|
scrollTarget != null -> {
|
||||||
val operationId = when (GitSettings.authMode) {
|
scrollTarget?.let { recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) }
|
||||||
AuthMode.None -> BaseGitActivity.GitOp.PULL
|
scrollTarget = null
|
||||||
else -> BaseGitActivity.GitOp.SYNC
|
}
|
||||||
}
|
else -> {
|
||||||
requireStore().apply {
|
// When the result is not filtered and there is a saved scroll position for
|
||||||
lifecycleScope.launch {
|
// it,
|
||||||
launchGitOperation(operationId).fold(
|
// we try to restore it.
|
||||||
success = {
|
recyclerViewStateToRestore?.let { recyclerView.layoutManager!!.onRestoreInstanceState(it) }
|
||||||
binding.swipeRefresher.isRefreshing = false
|
recyclerViewStateToRestore = null
|
||||||
refreshPasswordList()
|
}
|
||||||
},
|
|
||||||
failure = { err ->
|
|
||||||
promptOnErrorHandler(err) {
|
|
||||||
binding.swipeRefresher.isRefreshing = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recyclerAdapter = PasswordItemRecyclerAdapter()
|
|
||||||
.onItemClicked { _, item ->
|
|
||||||
listener.onFragmentInteraction(item)
|
|
||||||
}
|
|
||||||
.onSelectionChanged { selection ->
|
|
||||||
// In order to not interfere with drag selection, we disable the SwipeRefreshLayout
|
|
||||||
// once an item is selected.
|
|
||||||
binding.swipeRefresher.isEnabled = selection.isEmpty
|
|
||||||
|
|
||||||
if (actionMode == null)
|
|
||||||
actionMode = requireStore().startSupportActionMode(actionModeCallback)
|
|
||||||
?: return@onSelectionChanged
|
|
||||||
|
|
||||||
if (!selection.isEmpty) {
|
|
||||||
actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size())
|
|
||||||
actionMode!!.invalidate()
|
|
||||||
} else {
|
|
||||||
actionMode!!.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val recyclerView = binding.passRecycler
|
|
||||||
recyclerView.apply {
|
|
||||||
addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
|
|
||||||
layoutManager = LinearLayoutManager(requireContext())
|
|
||||||
itemAnimator = OnOffItemAnimator()
|
|
||||||
adapter = recyclerAdapter
|
|
||||||
}
|
|
||||||
|
|
||||||
FastScrollerBuilder(recyclerView).build()
|
|
||||||
recyclerAdapter.makeSelectable(recyclerView)
|
|
||||||
registerForContextMenu(recyclerView)
|
|
||||||
|
|
||||||
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
|
|
||||||
model.navigateTo(File(path), pushPreviousLocation = false)
|
|
||||||
model.searchResult.observe(viewLifecycleOwner) { result ->
|
|
||||||
// Only run animations when the new list is filtered, i.e., the user submitted a search,
|
|
||||||
// and not on folder navigations since the latter leads to too many removal animations.
|
|
||||||
(recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
|
|
||||||
recyclerAdapter.submitList(result.passwordItems) {
|
|
||||||
when {
|
|
||||||
result.isFiltered -> {
|
|
||||||
// When the result is filtered, we always scroll to the top since that is where
|
|
||||||
// the best fuzzy match appears.
|
|
||||||
recyclerView.scrollToPosition(0)
|
|
||||||
}
|
|
||||||
scrollTarget != null -> {
|
|
||||||
scrollTarget?.let {
|
|
||||||
recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it))
|
|
||||||
}
|
|
||||||
scrollTarget = null
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// When the result is not filtered and there is a saved scroll position for it,
|
|
||||||
// we try to restore it.
|
|
||||||
recyclerViewStateToRestore?.let {
|
|
||||||
recyclerView.layoutManager!!.onRestoreInstanceState(it)
|
|
||||||
}
|
|
||||||
recyclerViewStateToRestore = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val actionModeCallback = object : ActionMode.Callback {
|
private val actionModeCallback =
|
||||||
// Called when the action mode is created; startActionMode() was called
|
object : ActionMode.Callback {
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
// Called when the action mode is created; startActionMode() was called
|
||||||
// Inflate a menu resource providing context menu items
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
mode.menuInflater.inflate(R.menu.context_pass, menu)
|
// Inflate a menu resource providing context menu items
|
||||||
// hide the fab
|
mode.menuInflater.inflate(R.menu.context_pass, menu)
|
||||||
animateFab(false)
|
// hide the fab
|
||||||
return true
|
animateFab(false)
|
||||||
}
|
|
||||||
|
|
||||||
// Called each time the action mode is shown. Always called after onCreateActionMode, but
|
|
||||||
// may be called multiple times if the mode is invalidated.
|
|
||||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
menu.findItem(R.id.menu_edit_password).isVisible =
|
|
||||||
recyclerAdapter.getSelectedItems()
|
|
||||||
.all { it.type == PasswordItem.TYPE_CATEGORY }
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called when the user selects a contextual menu item
|
|
||||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
|
||||||
return when (item.itemId) {
|
|
||||||
R.id.menu_delete_password -> {
|
|
||||||
requireStore().deletePasswords(recyclerAdapter.getSelectedItems())
|
|
||||||
// Action picked, so close the CAB
|
|
||||||
mode.finish()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
R.id.menu_move_password -> {
|
|
||||||
requireStore().movePasswords(recyclerAdapter.getSelectedItems())
|
|
||||||
false
|
|
||||||
}
|
|
||||||
R.id.menu_edit_password -> {
|
|
||||||
requireStore().renameCategory(recyclerAdapter.getSelectedItems())
|
|
||||||
mode.finish()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called when the user exits the action mode
|
|
||||||
override fun onDestroyActionMode(mode: ActionMode) {
|
|
||||||
recyclerAdapter.requireSelectionTracker().clearSelection()
|
|
||||||
actionMode = null
|
|
||||||
// show the fab
|
|
||||||
animateFab(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun animateFab(show: Boolean) = with(binding.fab) {
|
|
||||||
val animation = AnimationUtils.loadAnimation(
|
|
||||||
context, if (show) R.anim.scale_up else R.anim.scale_down
|
|
||||||
)
|
|
||||||
animation.setAnimationListener(object : Animation.AnimationListener {
|
|
||||||
override fun onAnimationRepeat(animation: Animation?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAnimationEnd(animation: Animation?) {
|
|
||||||
if (!show) visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAnimationStart(animation: Animation?) {
|
|
||||||
if (show) visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
})
|
|
||||||
animate().rotationBy(if (show) -90f else 90f)
|
|
||||||
.setStartDelay(if (show) 100 else 0)
|
|
||||||
.setDuration(100)
|
|
||||||
.start()
|
|
||||||
startAnimation(animation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAttach(context: Context) {
|
|
||||||
super.onAttach(context)
|
|
||||||
runCatching {
|
|
||||||
listener = object : OnFragmentInteractionListener {
|
|
||||||
override fun onFragmentInteraction(item: PasswordItem) {
|
|
||||||
if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) {
|
|
||||||
//save the time when password was used
|
|
||||||
val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
|
||||||
preferences.edit {
|
|
||||||
putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
|
||||||
navigateTo(item.file)
|
|
||||||
} else {
|
|
||||||
if (requireArguments().getBoolean("matchWith", false)) {
|
|
||||||
requireStore().matchPasswordWithApp(item)
|
|
||||||
} else {
|
|
||||||
requireStore().decryptPassword(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
throw ClassCastException("$context must implement OnFragmentInteractionListener")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requireStore() = requireActivity() as PasswordStore
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns true if the back press was handled by the [Fragment].
|
|
||||||
*/
|
|
||||||
fun onBackPressedInActivity(): Boolean {
|
|
||||||
if (!model.canNavigateBack)
|
|
||||||
return false
|
|
||||||
// The RecyclerView state is restored when the asynchronous update operation on the
|
|
||||||
// adapter is completed.
|
|
||||||
recyclerViewStateToRestore = model.navigateBack()
|
|
||||||
if (!model.canNavigateBack)
|
|
||||||
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun dismissActionMode() {
|
// Called each time the action mode is shown. Always called after onCreateActionMode,
|
||||||
actionMode?.finish()
|
// but
|
||||||
}
|
// may be called multiple times if the mode is invalidated.
|
||||||
|
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
menu.findItem(R.id.menu_edit_password).isVisible =
|
||||||
|
recyclerAdapter.getSelectedItems().all { it.type == PasswordItem.TYPE_CATEGORY }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
// Called when the user selects a contextual menu item
|
||||||
|
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||||
|
return when (item.itemId) {
|
||||||
|
R.id.menu_delete_password -> {
|
||||||
|
requireStore().deletePasswords(recyclerAdapter.getSelectedItems())
|
||||||
|
// Action picked, so close the CAB
|
||||||
|
mode.finish()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
R.id.menu_move_password -> {
|
||||||
|
requireStore().movePasswords(recyclerAdapter.getSelectedItems())
|
||||||
|
false
|
||||||
|
}
|
||||||
|
R.id.menu_edit_password -> {
|
||||||
|
requireStore().renameCategory(recyclerAdapter.getSelectedItems())
|
||||||
|
mode.finish()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const val ITEM_CREATION_REQUEST_KEY = "creation_key"
|
// Called when the user exits the action mode
|
||||||
const val ACTION_KEY = "action"
|
override fun onDestroyActionMode(mode: ActionMode) {
|
||||||
const val ACTION_FOLDER = "folder"
|
recyclerAdapter.requireSelectionTracker().clearSelection()
|
||||||
const val ACTION_PASSWORD = "password"
|
actionMode = null
|
||||||
|
// show the fab
|
||||||
|
animateFab(true)
|
||||||
|
}
|
||||||
|
|
||||||
fun newInstance(args: Bundle): PasswordFragment {
|
private fun animateFab(show: Boolean) =
|
||||||
val fragment = PasswordFragment()
|
with(binding.fab) {
|
||||||
fragment.arguments = args
|
val animation = AnimationUtils.loadAnimation(context, if (show) R.anim.scale_up else R.anim.scale_down)
|
||||||
return fragment
|
animation.setAnimationListener(
|
||||||
|
object : Animation.AnimationListener {
|
||||||
|
override fun onAnimationRepeat(animation: Animation?) {}
|
||||||
|
|
||||||
|
override fun onAnimationEnd(animation: Animation?) {
|
||||||
|
if (!show) visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAnimationStart(animation: Animation?) {
|
||||||
|
if (show) visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
animate().rotationBy(if (show) -90f else 90f).setStartDelay(if (show) 100 else 0).setDuration(100).start()
|
||||||
|
startAnimation(animation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
runCatching {
|
||||||
|
listener =
|
||||||
|
object : OnFragmentInteractionListener {
|
||||||
|
override fun onFragmentInteraction(item: PasswordItem) {
|
||||||
|
if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) {
|
||||||
|
// save the time when password was used
|
||||||
|
val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
||||||
|
preferences.edit { putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
fun navigateTo(file: File) {
|
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
||||||
requireStore().clearSearch()
|
navigateTo(item.file)
|
||||||
model.navigateTo(
|
} else {
|
||||||
file,
|
if (requireArguments().getBoolean("matchWith", false)) {
|
||||||
recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState()
|
requireStore().matchPasswordWithApp(item)
|
||||||
)
|
} else {
|
||||||
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
requireStore().decryptPassword(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.onFailure { throw ClassCastException("$context must implement OnFragmentInteractionListener") }
|
||||||
|
}
|
||||||
|
|
||||||
fun scrollToOnNextRefresh(file: File) {
|
private fun requireStore() = requireActivity() as PasswordStore
|
||||||
scrollTarget = file
|
|
||||||
|
/** Returns true if the back press was handled by the [Fragment]. */
|
||||||
|
fun onBackPressedInActivity(): Boolean {
|
||||||
|
if (!model.canNavigateBack) return false
|
||||||
|
// The RecyclerView state is restored when the asynchronous update operation on the
|
||||||
|
// adapter is completed.
|
||||||
|
recyclerViewStateToRestore = model.navigateBack()
|
||||||
|
if (!model.canNavigateBack) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissActionMode() {
|
||||||
|
actionMode?.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val ITEM_CREATION_REQUEST_KEY = "creation_key"
|
||||||
|
const val ACTION_KEY = "action"
|
||||||
|
const val ACTION_FOLDER = "folder"
|
||||||
|
const val ACTION_PASSWORD = "password"
|
||||||
|
|
||||||
|
fun newInstance(args: Bundle): PasswordFragment {
|
||||||
|
val fragment = PasswordFragment()
|
||||||
|
fragment.arguments = args
|
||||||
|
return fragment
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface OnFragmentInteractionListener {
|
fun navigateTo(file: File) {
|
||||||
|
requireStore().clearSearch()
|
||||||
|
model.navigateTo(file, recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState())
|
||||||
|
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
fun onFragmentInteraction(item: PasswordItem)
|
fun scrollToOnNextRefresh(file: File) {
|
||||||
}
|
scrollTarget = file
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnFragmentInteractionListener {
|
||||||
|
|
||||||
|
fun onFragmentInteraction(item: PasswordItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -27,49 +27,39 @@ private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex()
|
||||||
|
|
||||||
class ProxySelectorActivity : AppCompatActivity() {
|
class ProxySelectorActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private val binding by viewBinding(ActivityProxySelectorBinding::inflate)
|
private val binding by viewBinding(ActivityProxySelectorBinding::inflate)
|
||||||
private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) { applicationContext.getEncryptedProxyPrefs() }
|
private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) { applicationContext.getEncryptedProxyPrefs() }
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
with(binding) {
|
with(binding) {
|
||||||
proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST))
|
proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST))
|
||||||
proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME))
|
proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME))
|
||||||
proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let {
|
proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let { proxyPort.setText("$it") }
|
||||||
proxyPort.setText("$it")
|
proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD))
|
||||||
}
|
save.setOnClickListener { saveSettings() }
|
||||||
proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD))
|
proxyHost.doOnTextChanged { text, _, _, _ ->
|
||||||
save.setOnClickListener { saveSettings() }
|
if (text != null) {
|
||||||
proxyHost.doOnTextChanged { text, _, _, _ ->
|
proxyHost.error =
|
||||||
if (text != null) {
|
if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) {
|
||||||
proxyHost.error = if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) {
|
null
|
||||||
null
|
} else {
|
||||||
} else {
|
getString(R.string.invalid_proxy_url)
|
||||||
getString(R.string.invalid_proxy_url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveSettings() {
|
private fun saveSettings() {
|
||||||
proxyPrefs.edit {
|
proxyPrefs.edit {
|
||||||
binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let {
|
binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyHost = it }
|
||||||
GitSettings.proxyHost = it
|
binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyUsername = it }
|
||||||
}
|
binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let { GitSettings.proxyPort = it.toInt() }
|
||||||
binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let {
|
binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyPassword = it }
|
||||||
GitSettings.proxyUsername = it
|
|
||||||
}
|
|
||||||
binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let {
|
|
||||||
GitSettings.proxyPort = it.toInt()
|
|
||||||
}
|
|
||||||
binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let {
|
|
||||||
GitSettings.proxyPassword = it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ProxyUtils.setDefaultProxy()
|
|
||||||
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
|
||||||
}
|
}
|
||||||
|
ProxyUtils.setDefaultProxy()
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,94 +33,94 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
|
|
||||||
class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider {
|
class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider {
|
||||||
|
|
||||||
private val isAutofillServiceEnabled: Boolean
|
private val isAutofillServiceEnabled: Boolean
|
||||||
get() {
|
get() {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
|
||||||
return activity.autofillManager?.hasEnabledAutofillServices() == true
|
return activity.autofillManager?.hasEnabledAutofillServices() == true
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
private fun showAutofillDialog(pref: SwitchPreference) {
|
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
|
||||||
when (event) {
|
|
||||||
Lifecycle.Event.ON_RESUME -> {
|
|
||||||
pref.checked = isAutofillServiceEnabled
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MaterialAlertDialogBuilder(activity).run {
|
|
||||||
setTitle(R.string.pref_autofill_enable_title)
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
val layout =
|
|
||||||
activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
|
|
||||||
val supportedBrowsersTextView =
|
|
||||||
layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
|
|
||||||
supportedBrowsersTextView.text =
|
|
||||||
getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(
|
|
||||||
separator = "\n"
|
|
||||||
) {
|
|
||||||
val appLabel = it.first
|
|
||||||
val supportDescription = when (it.second) {
|
|
||||||
BrowserAutofillSupportLevel.None -> activity.getString(R.string.oreo_autofill_no_support)
|
|
||||||
BrowserAutofillSupportLevel.FlakyFill -> activity.getString(R.string.oreo_autofill_flaky_fill_support)
|
|
||||||
BrowserAutofillSupportLevel.PasswordFill -> activity.getString(R.string.oreo_autofill_password_fill_support)
|
|
||||||
BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility -> activity.getString(R.string.oreo_autofill_password_fill_and_conditional_save_support)
|
|
||||||
BrowserAutofillSupportLevel.GeneralFill -> activity.getString(R.string.oreo_autofill_general_fill_support)
|
|
||||||
BrowserAutofillSupportLevel.GeneralFillAndSave -> activity.getString(R.string.oreo_autofill_general_fill_and_save_support)
|
|
||||||
}
|
|
||||||
"$appLabel: $supportDescription"
|
|
||||||
}
|
|
||||||
setView(layout)
|
|
||||||
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
|
||||||
val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
|
|
||||||
data = Uri.parse("package:${BuildConfig.APPLICATION_ID}")
|
|
||||||
}
|
|
||||||
activity.startActivity(intent)
|
|
||||||
}
|
|
||||||
setNegativeButton(R.string.dialog_cancel, null)
|
|
||||||
setOnDismissListener { pref.checked = isAutofillServiceEnabled }
|
|
||||||
activity.lifecycle.addObserver(observer)
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
builder.apply {
|
private fun showAutofillDialog(pref: SwitchPreference) {
|
||||||
switch(PreferenceKeys.AUTOFILL_ENABLE) {
|
val observer = LifecycleEventObserver { _, event ->
|
||||||
titleRes = R.string.pref_autofill_enable_title
|
when (event) {
|
||||||
visible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
defaultValue = isAutofillServiceEnabled
|
pref.checked = isAutofillServiceEnabled
|
||||||
onClick {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return@onClick true
|
|
||||||
if (isAutofillServiceEnabled) {
|
|
||||||
activity.autofillManager?.disableAutofillServices()
|
|
||||||
} else {
|
|
||||||
showAutofillDialog(this)
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val values = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_values)
|
|
||||||
val titles = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_entries)
|
|
||||||
val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) }
|
|
||||||
singleChoice(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE, items) {
|
|
||||||
initialSelection = DirectoryStructure.DEFAULT.value
|
|
||||||
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
|
||||||
titleRes = R.string.oreo_autofill_preference_directory_structure
|
|
||||||
}
|
|
||||||
editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) {
|
|
||||||
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
|
||||||
titleRes = R.string.preference_default_username_title
|
|
||||||
summaryProvider = { activity.getString(R.string.preference_default_username_summary) }
|
|
||||||
}
|
|
||||||
editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) {
|
|
||||||
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
|
||||||
titleRes = R.string.preference_custom_public_suffixes_title
|
|
||||||
summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) }
|
|
||||||
textInputHintRes = R.string.preference_custom_public_suffixes_hint
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
MaterialAlertDialogBuilder(activity).run {
|
||||||
|
setTitle(R.string.pref_autofill_enable_title)
|
||||||
|
@SuppressLint("InflateParams")
|
||||||
|
val layout = activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
|
||||||
|
val supportedBrowsersTextView = layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
|
||||||
|
supportedBrowsersTextView.text =
|
||||||
|
getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(separator = "\n") {
|
||||||
|
val appLabel = it.first
|
||||||
|
val supportDescription =
|
||||||
|
when (it.second) {
|
||||||
|
BrowserAutofillSupportLevel.None -> activity.getString(R.string.oreo_autofill_no_support)
|
||||||
|
BrowserAutofillSupportLevel.FlakyFill -> activity.getString(R.string.oreo_autofill_flaky_fill_support)
|
||||||
|
BrowserAutofillSupportLevel.PasswordFill ->
|
||||||
|
activity.getString(R.string.oreo_autofill_password_fill_support)
|
||||||
|
BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility ->
|
||||||
|
activity.getString(R.string.oreo_autofill_password_fill_and_conditional_save_support)
|
||||||
|
BrowserAutofillSupportLevel.GeneralFill -> activity.getString(R.string.oreo_autofill_general_fill_support)
|
||||||
|
BrowserAutofillSupportLevel.GeneralFillAndSave ->
|
||||||
|
activity.getString(R.string.oreo_autofill_general_fill_and_save_support)
|
||||||
|
}
|
||||||
|
"$appLabel: $supportDescription"
|
||||||
|
}
|
||||||
|
setView(layout)
|
||||||
|
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||||
|
val intent =
|
||||||
|
Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
|
||||||
|
data = Uri.parse("package:${BuildConfig.APPLICATION_ID}")
|
||||||
|
}
|
||||||
|
activity.startActivity(intent)
|
||||||
|
}
|
||||||
|
setNegativeButton(R.string.dialog_cancel, null)
|
||||||
|
setOnDismissListener { pref.checked = isAutofillServiceEnabled }
|
||||||
|
activity.lifecycle.addObserver(observer)
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||||
|
builder.apply {
|
||||||
|
switch(PreferenceKeys.AUTOFILL_ENABLE) {
|
||||||
|
titleRes = R.string.pref_autofill_enable_title
|
||||||
|
visible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||||
|
defaultValue = isAutofillServiceEnabled
|
||||||
|
onClick {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return@onClick true
|
||||||
|
if (isAutofillServiceEnabled) {
|
||||||
|
activity.autofillManager?.disableAutofillServices()
|
||||||
|
} else {
|
||||||
|
showAutofillDialog(this)
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val values = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_values)
|
||||||
|
val titles = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_entries)
|
||||||
|
val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) }
|
||||||
|
singleChoice(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE, items) {
|
||||||
|
initialSelection = DirectoryStructure.DEFAULT.value
|
||||||
|
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
||||||
|
titleRes = R.string.oreo_autofill_preference_directory_structure
|
||||||
|
}
|
||||||
|
editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) {
|
||||||
|
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
||||||
|
titleRes = R.string.preference_default_username_title
|
||||||
|
summaryProvider = { activity.getString(R.string.preference_default_username_summary) }
|
||||||
|
}
|
||||||
|
editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) {
|
||||||
|
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
||||||
|
titleRes = R.string.preference_custom_public_suffixes_title
|
||||||
|
summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) }
|
||||||
|
textInputHintRes = R.string.preference_custom_public_suffixes_hint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,37 +20,38 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
|
|
||||||
class DirectorySelectionActivity : AppCompatActivity() {
|
class DirectorySelectionActivity : AppCompatActivity() {
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private val directorySelectAction = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
|
private val directorySelectAction =
|
||||||
if (uri == null) return@registerForActivityResult
|
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
|
||||||
|
if (uri == null) return@registerForActivityResult
|
||||||
|
|
||||||
d { "Selected repository URI is $uri" }
|
d { "Selected repository URI is $uri" }
|
||||||
// TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile
|
// TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile
|
||||||
val docId = DocumentsContract.getTreeDocumentId(uri)
|
val docId = DocumentsContract.getTreeDocumentId(uri)
|
||||||
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||||
val path = if (split.size > 1) split[1] else split[0]
|
val path = if (split.size > 1) split[1] else split[0]
|
||||||
val repoPath = "${Environment.getExternalStorageDirectory()}/$path"
|
val repoPath = "${Environment.getExternalStorageDirectory()}/$path"
|
||||||
val prefs = sharedPrefs
|
val prefs = sharedPrefs
|
||||||
|
|
||||||
d { "Selected repository path is $repoPath" }
|
d { "Selected repository path is $repoPath" }
|
||||||
|
|
||||||
if (Environment.getExternalStorageDirectory().path == repoPath) {
|
if (Environment.getExternalStorageDirectory().path == repoPath) {
|
||||||
MaterialAlertDialogBuilder(this)
|
MaterialAlertDialogBuilder(this)
|
||||||
.setTitle(resources.getString(R.string.sdcard_root_warning_title))
|
.setTitle(resources.getString(R.string.sdcard_root_warning_title))
|
||||||
.setMessage(resources.getString(R.string.sdcard_root_warning_message))
|
.setMessage(resources.getString(R.string.sdcard_root_warning_message))
|
||||||
.setPositiveButton(resources.getString(R.string.sdcard_root_warning_remove_everything)) { _, _ ->
|
.setPositiveButton(resources.getString(R.string.sdcard_root_warning_remove_everything)) { _, _ ->
|
||||||
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) }
|
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) }
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.dialog_cancel, null)
|
.setNegativeButton(R.string.dialog_cancel, null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) }
|
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) }
|
||||||
setResult(RESULT_OK)
|
setResult(RESULT_OK)
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
directorySelectAction.launch(null)
|
directorySelectAction.launch(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,83 +22,85 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
|
|
||||||
class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider {
|
class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider {
|
||||||
|
|
||||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||||
builder.apply {
|
builder.apply {
|
||||||
val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
|
val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
|
||||||
val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
|
val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
|
||||||
val themeItems = themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
|
val themeItems = themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
|
||||||
singleChoice(PreferenceKeys.APP_THEME, themeItems) {
|
singleChoice(PreferenceKeys.APP_THEME, themeItems) {
|
||||||
initialSelection = activity.resources.getString(R.string.app_theme_def)
|
initialSelection = activity.resources.getString(R.string.app_theme_def)
|
||||||
titleRes = R.string.pref_app_theme_title
|
titleRes = R.string.pref_app_theme_title
|
||||||
}
|
}
|
||||||
|
|
||||||
val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
|
val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
|
||||||
val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
|
val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
|
||||||
val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
|
val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
|
||||||
singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
|
singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
|
||||||
initialSelection = sortValues[0]
|
initialSelection = sortValues[0]
|
||||||
titleRes = R.string.pref_sort_order_title
|
titleRes = R.string.pref_sort_order_title
|
||||||
}
|
}
|
||||||
|
|
||||||
checkBox(PreferenceKeys.FILTER_RECURSIVELY) {
|
checkBox(PreferenceKeys.FILTER_RECURSIVELY) {
|
||||||
titleRes = R.string.pref_recursive_filter_title
|
titleRes = R.string.pref_recursive_filter_title
|
||||||
summaryRes = R.string.pref_recursive_filter_summary
|
summaryRes = R.string.pref_recursive_filter_summary
|
||||||
defaultValue = true
|
defaultValue = true
|
||||||
}
|
}
|
||||||
|
|
||||||
checkBox(PreferenceKeys.SEARCH_ON_START) {
|
checkBox(PreferenceKeys.SEARCH_ON_START) {
|
||||||
titleRes = R.string.pref_search_on_start_title
|
titleRes = R.string.pref_search_on_start_title
|
||||||
summaryRes = R.string.pref_search_on_start_summary
|
summaryRes = R.string.pref_search_on_start_summary
|
||||||
defaultValue = false
|
defaultValue = false
|
||||||
}
|
}
|
||||||
|
|
||||||
checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
|
checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
|
||||||
titleRes = R.string.pref_show_hidden_title
|
titleRes = R.string.pref_show_hidden_title
|
||||||
summaryRes = R.string.pref_show_hidden_summary
|
summaryRes = R.string.pref_show_hidden_summary
|
||||||
defaultValue = false
|
defaultValue = false
|
||||||
}
|
}
|
||||||
|
|
||||||
checkBox(PreferenceKeys.BIOMETRIC_AUTH) {
|
checkBox(PreferenceKeys.BIOMETRIC_AUTH) {
|
||||||
titleRes = R.string.pref_biometric_auth_title
|
titleRes = R.string.pref_biometric_auth_title
|
||||||
defaultValue = false
|
defaultValue = false
|
||||||
}.apply {
|
}
|
||||||
val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
|
.apply {
|
||||||
if (!canAuthenticate) {
|
val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
|
||||||
enabled = false
|
if (!canAuthenticate) {
|
||||||
checked = false
|
enabled = false
|
||||||
summaryRes = R.string.pref_biometric_auth_summary_error
|
checked = false
|
||||||
} else {
|
summaryRes = R.string.pref_biometric_auth_summary_error
|
||||||
summaryRes = R.string.pref_biometric_auth_summary
|
} else {
|
||||||
onClick {
|
summaryRes = R.string.pref_biometric_auth_summary
|
||||||
enabled = false
|
onClick {
|
||||||
val isChecked = checked
|
enabled = false
|
||||||
activity.sharedPrefs.edit {
|
val isChecked = checked
|
||||||
BiometricAuthenticator.authenticate(activity) { result ->
|
activity.sharedPrefs.edit {
|
||||||
when (result) {
|
BiometricAuthenticator.authenticate(activity) { result ->
|
||||||
is BiometricAuthenticator.Result.Success -> {
|
when (result) {
|
||||||
// Apply the changes
|
is BiometricAuthenticator.Result.Success -> {
|
||||||
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked)
|
// Apply the changes
|
||||||
enabled = true
|
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked)
|
||||||
}
|
enabled = true
|
||||||
else -> {
|
|
||||||
// If any error occurs, revert back to the previous state. This
|
|
||||||
// catch-all clause includes the cancellation case.
|
|
||||||
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked)
|
|
||||||
checked = !isChecked
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
|
||||||
activity.getSystemService<ShortcutManager>()?.apply {
|
|
||||||
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
else -> {
|
||||||
|
// If any error occurs, revert back to the previous
|
||||||
|
// state. This
|
||||||
|
// catch-all clause includes the cancellation case.
|
||||||
|
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked)
|
||||||
|
checked = !isChecked
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||||
|
activity.getSystemService<ShortcutManager>()?.apply {
|
||||||
|
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,54 +23,59 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
|
|
||||||
class MiscSettings(activity: FragmentActivity) : SettingsProvider {
|
class MiscSettings(activity: FragmentActivity) : SettingsProvider {
|
||||||
|
|
||||||
private val storeExportAction = activity.registerForActivityResult(object : ActivityResultContracts.OpenDocumentTree() {
|
private val storeExportAction =
|
||||||
|
activity.registerForActivityResult(
|
||||||
|
object : ActivityResultContracts.OpenDocumentTree() {
|
||||||
override fun createIntent(context: Context, input: Uri?): Intent {
|
override fun createIntent(context: Context, input: Uri?): Intent {
|
||||||
return super.createIntent(context, input).apply {
|
return super.createIntent(context, input).apply {
|
||||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
flags =
|
||||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||||
}
|
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}) { uri: Uri? ->
|
}
|
||||||
if (uri == null) return@registerForActivityResult
|
) { uri: Uri? ->
|
||||||
val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
|
if (uri == null) return@registerForActivityResult
|
||||||
|
val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
|
||||||
|
|
||||||
if (targetDirectory != null) {
|
if (targetDirectory != null) {
|
||||||
val service = Intent(activity.applicationContext, PasswordExportService::class.java).apply {
|
val service =
|
||||||
action = PasswordExportService.ACTION_EXPORT_PASSWORD
|
Intent(activity.applicationContext, PasswordExportService::class.java).apply {
|
||||||
putExtra("uri", uri)
|
action = PasswordExportService.ACTION_EXPORT_PASSWORD
|
||||||
}
|
putExtra("uri", uri)
|
||||||
|
}
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
activity.startForegroundService(service)
|
activity.startForegroundService(service)
|
||||||
} else {
|
} else {
|
||||||
activity.startService(service)
|
activity.startService(service)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||||
builder.apply {
|
builder.apply {
|
||||||
pref(PreferenceKeys.EXPORT_PASSWORDS) {
|
pref(PreferenceKeys.EXPORT_PASSWORDS) {
|
||||||
titleRes = R.string.prefs_export_passwords_title
|
titleRes = R.string.prefs_export_passwords_title
|
||||||
summaryRes = R.string.prefs_export_passwords_summary
|
summaryRes = R.string.prefs_export_passwords_summary
|
||||||
onClick {
|
onClick {
|
||||||
storeExportAction.launch(null)
|
storeExportAction.launch(null)
|
||||||
true
|
true
|
||||||
}
|
|
||||||
}
|
|
||||||
checkBox(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) {
|
|
||||||
defaultValue = false
|
|
||||||
titleRes = R.string.pref_clear_clipboard_title
|
|
||||||
summaryRes = R.string.pref_clear_clipboard_summary
|
|
||||||
}
|
|
||||||
checkBox(PreferenceKeys.ENABLE_DEBUG_LOGGING) {
|
|
||||||
defaultValue = false
|
|
||||||
titleRes = R.string.pref_debug_logging_title
|
|
||||||
summaryRes = R.string.pref_debug_logging_summary
|
|
||||||
visible = !BuildConfig.DEBUG
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
checkBox(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) {
|
||||||
|
defaultValue = false
|
||||||
|
titleRes = R.string.pref_clear_clipboard_title
|
||||||
|
summaryRes = R.string.pref_clear_clipboard_summary
|
||||||
|
}
|
||||||
|
checkBox(PreferenceKeys.ENABLE_DEBUG_LOGGING) {
|
||||||
|
defaultValue = false
|
||||||
|
titleRes = R.string.pref_debug_logging_title
|
||||||
|
summaryRes = R.string.pref_debug_logging_summary
|
||||||
|
visible = !BuildConfig.DEBUG
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,85 +29,90 @@ import java.io.File
|
||||||
|
|
||||||
class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider {
|
class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider {
|
||||||
|
|
||||||
private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs }
|
private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs }
|
||||||
private val storeCustomXkpwdDictionaryAction = activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
private val storeCustomXkpwdDictionaryAction =
|
||||||
if (uri == null) return@registerForActivityResult
|
activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri == null) return@registerForActivityResult
|
||||||
|
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
activity,
|
activity,
|
||||||
activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path),
|
activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path),
|
||||||
Toast.LENGTH_SHORT
|
Toast.LENGTH_SHORT
|
||||||
).show()
|
)
|
||||||
|
.show()
|
||||||
|
|
||||||
sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) }
|
sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) }
|
||||||
|
|
||||||
val inputStream = activity.contentResolver.openInputStream(uri)
|
val inputStream = activity.contentResolver.openInputStream(uri)
|
||||||
val customDictFile = File(activity.filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream()
|
val customDictFile = File(activity.filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream()
|
||||||
inputStream?.copyTo(customDictFile, 1024)
|
inputStream?.copyTo(customDictFile, 1024)
|
||||||
inputStream?.close()
|
inputStream?.close()
|
||||||
customDictFile.close()
|
customDictFile.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||||
builder.apply {
|
builder.apply {
|
||||||
val customDictPref = CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply {
|
val customDictPref =
|
||||||
titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title
|
CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply {
|
||||||
summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off
|
titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title
|
||||||
summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on
|
summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off
|
||||||
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
|
summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on
|
||||||
onCheckedChange {
|
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
|
||||||
requestRebind()
|
onCheckedChange {
|
||||||
true
|
requestRebind()
|
||||||
}
|
true
|
||||||
}
|
}
|
||||||
val customDictPathPref = Preference(PreferenceKeys.PREF_KEY_CUSTOM_DICT).apply {
|
|
||||||
dependency = PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT
|
|
||||||
titleRes = R.string.pref_xkpwgen_custom_dict_picker_title
|
|
||||||
summary = sharedPrefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
|
|
||||||
?: activity.resources.getString(R.string.pref_xkpwgen_custom_dict_picker_summary)
|
|
||||||
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
|
|
||||||
onClick {
|
|
||||||
storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*"))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
|
|
||||||
val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels)
|
|
||||||
val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) }
|
|
||||||
singleChoice(
|
|
||||||
PreferenceKeys.PREF_KEY_PWGEN_TYPE,
|
|
||||||
items,
|
|
||||||
) {
|
|
||||||
initialSelection = "classic"
|
|
||||||
titleRes = R.string.pref_password_generator_type_title
|
|
||||||
onSelectionChange { selection ->
|
|
||||||
val xkpasswdEnabled = selection == "xkpasswd"
|
|
||||||
customDictPathPref.visible = xkpasswdEnabled
|
|
||||||
customDictPref.visible = xkpasswdEnabled
|
|
||||||
customDictPref.requestRebind()
|
|
||||||
customDictPathPref.requestRebind()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// We initialize them early and add them manually to be able to manually force a rebind
|
|
||||||
// when the password generator type is changed.
|
|
||||||
addPreferenceItem(customDictPref)
|
|
||||||
addPreferenceItem(customDictPathPref)
|
|
||||||
editText(PreferenceKeys.GENERAL_SHOW_TIME) {
|
|
||||||
titleRes = R.string.pref_clipboard_timeout_title
|
|
||||||
summaryProvider = { activity.getString(R.string.pref_clipboard_timeout_summary) }
|
|
||||||
textInputType = InputType.TYPE_CLASS_NUMBER
|
|
||||||
}
|
|
||||||
checkBox(PreferenceKeys.SHOW_PASSWORD) {
|
|
||||||
titleRes = R.string.show_password_pref_title
|
|
||||||
summaryRes = R.string.show_password_pref_summary
|
|
||||||
defaultValue = true
|
|
||||||
}
|
|
||||||
checkBox(PreferenceKeys.COPY_ON_DECRYPT) {
|
|
||||||
titleRes = R.string.pref_copy_title
|
|
||||||
summaryRes = R.string.pref_copy_summary
|
|
||||||
defaultValue = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
val customDictPathPref =
|
||||||
|
Preference(PreferenceKeys.PREF_KEY_CUSTOM_DICT).apply {
|
||||||
|
dependency = PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT
|
||||||
|
titleRes = R.string.pref_xkpwgen_custom_dict_picker_title
|
||||||
|
summary =
|
||||||
|
sharedPrefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
|
||||||
|
?: activity.resources.getString(R.string.pref_xkpwgen_custom_dict_picker_summary)
|
||||||
|
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
|
||||||
|
onClick {
|
||||||
|
storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*"))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
|
||||||
|
val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels)
|
||||||
|
val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) }
|
||||||
|
singleChoice(
|
||||||
|
PreferenceKeys.PREF_KEY_PWGEN_TYPE,
|
||||||
|
items,
|
||||||
|
) {
|
||||||
|
initialSelection = "classic"
|
||||||
|
titleRes = R.string.pref_password_generator_type_title
|
||||||
|
onSelectionChange { selection ->
|
||||||
|
val xkpasswdEnabled = selection == "xkpasswd"
|
||||||
|
customDictPathPref.visible = xkpasswdEnabled
|
||||||
|
customDictPref.visible = xkpasswdEnabled
|
||||||
|
customDictPref.requestRebind()
|
||||||
|
customDictPathPref.requestRebind()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We initialize them early and add them manually to be able to manually force a rebind
|
||||||
|
// when the password generator type is changed.
|
||||||
|
addPreferenceItem(customDictPref)
|
||||||
|
addPreferenceItem(customDictPathPref)
|
||||||
|
editText(PreferenceKeys.GENERAL_SHOW_TIME) {
|
||||||
|
titleRes = R.string.pref_clipboard_timeout_title
|
||||||
|
summaryProvider = { activity.getString(R.string.pref_clipboard_timeout_summary) }
|
||||||
|
textInputType = InputType.TYPE_CLASS_NUMBER
|
||||||
|
}
|
||||||
|
checkBox(PreferenceKeys.SHOW_PASSWORD) {
|
||||||
|
titleRes = R.string.show_password_pref_title
|
||||||
|
summaryRes = R.string.show_password_pref_summary
|
||||||
|
defaultValue = true
|
||||||
|
}
|
||||||
|
checkBox(PreferenceKeys.COPY_ON_DECRYPT) {
|
||||||
|
titleRes = R.string.pref_copy_title
|
||||||
|
summaryRes = R.string.pref_copy_summary
|
||||||
|
defaultValue = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,168 +37,165 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
|
|
||||||
class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider {
|
class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider {
|
||||||
|
|
||||||
private val encryptedPreferences by lazy(LazyThreadSafetyMode.NONE) { activity.getEncryptedGitPrefs() }
|
private val encryptedPreferences by lazy(LazyThreadSafetyMode.NONE) { activity.getEncryptedGitPrefs() }
|
||||||
|
|
||||||
private fun <T : FragmentActivity> launchActivity(clazz: Class<T>) {
|
private fun <T : FragmentActivity> launchActivity(clazz: Class<T>) {
|
||||||
activity.startActivity(Intent(activity, clazz))
|
activity.startActivity(Intent(activity, clazz))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectExternalGitRepository() {
|
private fun selectExternalGitRepository() {
|
||||||
MaterialAlertDialogBuilder(activity)
|
MaterialAlertDialogBuilder(activity)
|
||||||
.setTitle(activity.resources.getString(R.string.external_repository_dialog_title))
|
.setTitle(activity.resources.getString(R.string.external_repository_dialog_title))
|
||||||
.setMessage(activity.resources.getString(R.string.external_repository_dialog_text))
|
.setMessage(activity.resources.getString(R.string.external_repository_dialog_text))
|
||||||
.setPositiveButton(R.string.dialog_ok) { _, _ ->
|
.setPositiveButton(R.string.dialog_ok) { _, _ -> launchActivity(DirectorySelectionActivity::class.java) }
|
||||||
launchActivity(DirectorySelectionActivity::class.java)
|
.setNegativeButton(R.string.dialog_cancel, null)
|
||||||
}
|
.show()
|
||||||
.setNegativeButton(R.string.dialog_cancel, null)
|
}
|
||||||
.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||||
builder.apply {
|
builder.apply {
|
||||||
checkBox(PreferenceKeys.REBASE_ON_PULL) {
|
checkBox(PreferenceKeys.REBASE_ON_PULL) {
|
||||||
titleRes = R.string.pref_rebase_on_pull_title
|
titleRes = R.string.pref_rebase_on_pull_title
|
||||||
summaryRes = R.string.pref_rebase_on_pull_summary
|
summaryRes = R.string.pref_rebase_on_pull_summary
|
||||||
summaryOnRes = R.string.pref_rebase_on_pull_summary_on
|
summaryOnRes = R.string.pref_rebase_on_pull_summary_on
|
||||||
defaultValue = true
|
defaultValue = true
|
||||||
}
|
}
|
||||||
pref(PreferenceKeys.GIT_SERVER_INFO) {
|
pref(PreferenceKeys.GIT_SERVER_INFO) {
|
||||||
titleRes = R.string.pref_edit_git_server_settings
|
titleRes = R.string.pref_edit_git_server_settings
|
||||||
visible = PasswordRepository.isGitRepo()
|
visible = PasswordRepository.isGitRepo()
|
||||||
onClick {
|
onClick {
|
||||||
launchActivity(GitServerConfigActivity::class.java)
|
launchActivity(GitServerConfigActivity::class.java)
|
||||||
true
|
true
|
||||||
}
|
|
||||||
}
|
|
||||||
pref(PreferenceKeys.PROXY_SETTINGS) {
|
|
||||||
titleRes = R.string.pref_edit_proxy_settings
|
|
||||||
visible = GitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo()
|
|
||||||
onClick {
|
|
||||||
launchActivity(ProxySelectorActivity::class.java)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pref(PreferenceKeys.GIT_CONFIG) {
|
|
||||||
titleRes = R.string.pref_edit_git_config
|
|
||||||
visible = PasswordRepository.isGitRepo()
|
|
||||||
onClick {
|
|
||||||
launchActivity(GitConfigActivity::class.java)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pref(PreferenceKeys.SSH_KEY) {
|
|
||||||
titleRes = R.string.pref_import_ssh_key_title
|
|
||||||
visible = PasswordRepository.isGitRepo()
|
|
||||||
onClick {
|
|
||||||
launchActivity(SshKeyImportActivity::class.java)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pref(PreferenceKeys.SSH_KEYGEN) {
|
|
||||||
titleRes = R.string.pref_ssh_keygen_title
|
|
||||||
onClick {
|
|
||||||
launchActivity(SshKeyGenActivity::class.java)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pref(PreferenceKeys.SSH_SEE_KEY) {
|
|
||||||
titleRes = R.string.pref_ssh_see_key_title
|
|
||||||
visible = PasswordRepository.isGitRepo()
|
|
||||||
onClick {
|
|
||||||
ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pref(PreferenceKeys.CLEAR_SAVED_PASS) {
|
|
||||||
fun Preference.updatePref() {
|
|
||||||
val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
|
|
||||||
val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
|
|
||||||
if (sshPass == null && httpsPass == null) {
|
|
||||||
visible = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
when {
|
|
||||||
httpsPass != null -> titleRes = R.string.clear_saved_passphrase_https
|
|
||||||
sshPass != null -> titleRes = R.string.clear_saved_passphrase_ssh
|
|
||||||
}
|
|
||||||
visible = true
|
|
||||||
requestRebind()
|
|
||||||
}
|
|
||||||
onClick {
|
|
||||||
updatePref()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
updatePref()
|
|
||||||
}
|
|
||||||
pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
|
|
||||||
titleRes = R.string.pref_title_openkeystore_clear_keyid
|
|
||||||
visible = activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty()
|
|
||||||
?: false
|
|
||||||
onClick {
|
|
||||||
activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
|
|
||||||
visible = false
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val deleteRepoPref = pref(PreferenceKeys.GIT_DELETE_REPO) {
|
|
||||||
titleRes = R.string.pref_git_delete_repo_title
|
|
||||||
summaryRes = R.string.pref_git_delete_repo_summary
|
|
||||||
visible = !activity.sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
|
||||||
onClick {
|
|
||||||
val repoDir = PasswordRepository.getRepositoryDirectory()
|
|
||||||
MaterialAlertDialogBuilder(activity)
|
|
||||||
.setTitle(R.string.pref_dialog_delete_title)
|
|
||||||
.setMessage(activity.getString(R.string.dialog_delete_msg, repoDir))
|
|
||||||
.setCancelable(false)
|
|
||||||
.setPositiveButton(R.string.dialog_delete) { dialogInterface, _ ->
|
|
||||||
runCatching {
|
|
||||||
PasswordRepository.getRepositoryDirectory().deleteRecursively()
|
|
||||||
PasswordRepository.closeRepository()
|
|
||||||
}.onFailure {
|
|
||||||
it.message?.let { message ->
|
|
||||||
activity.snackbar(message = message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
|
||||||
activity.getSystemService<ShortcutManager>()?.apply {
|
|
||||||
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
|
|
||||||
dialogInterface.cancel()
|
|
||||||
activity.finish()
|
|
||||||
}
|
|
||||||
.setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> run { dialogInterface.cancel() } }
|
|
||||||
.show()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkBox(PreferenceKeys.GIT_EXTERNAL) {
|
|
||||||
titleRes = R.string.pref_external_repository_title
|
|
||||||
summaryRes = R.string.pref_external_repository_summary
|
|
||||||
onCheckedChange { checked ->
|
|
||||||
deleteRepoPref.visible = !checked
|
|
||||||
deleteRepoPref.requestRebind()
|
|
||||||
PasswordRepository.closeRepository()
|
|
||||||
activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) }
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pref(PreferenceKeys.GIT_EXTERNAL_REPO) {
|
|
||||||
val externalRepo = activity.sharedPrefs.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
|
||||||
if (externalRepo != null) {
|
|
||||||
summary = externalRepo
|
|
||||||
} else {
|
|
||||||
summaryRes = R.string.pref_select_external_repository_summary_no_repo_selected
|
|
||||||
}
|
|
||||||
titleRes = R.string.pref_select_external_repository_title
|
|
||||||
dependency = PreferenceKeys.GIT_EXTERNAL
|
|
||||||
onClick {
|
|
||||||
selectExternalGitRepository()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
pref(PreferenceKeys.PROXY_SETTINGS) {
|
||||||
|
titleRes = R.string.pref_edit_proxy_settings
|
||||||
|
visible = GitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo()
|
||||||
|
onClick {
|
||||||
|
launchActivity(ProxySelectorActivity::class.java)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pref(PreferenceKeys.GIT_CONFIG) {
|
||||||
|
titleRes = R.string.pref_edit_git_config
|
||||||
|
visible = PasswordRepository.isGitRepo()
|
||||||
|
onClick {
|
||||||
|
launchActivity(GitConfigActivity::class.java)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pref(PreferenceKeys.SSH_KEY) {
|
||||||
|
titleRes = R.string.pref_import_ssh_key_title
|
||||||
|
visible = PasswordRepository.isGitRepo()
|
||||||
|
onClick {
|
||||||
|
launchActivity(SshKeyImportActivity::class.java)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pref(PreferenceKeys.SSH_KEYGEN) {
|
||||||
|
titleRes = R.string.pref_ssh_keygen_title
|
||||||
|
onClick {
|
||||||
|
launchActivity(SshKeyGenActivity::class.java)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pref(PreferenceKeys.SSH_SEE_KEY) {
|
||||||
|
titleRes = R.string.pref_ssh_see_key_title
|
||||||
|
visible = PasswordRepository.isGitRepo()
|
||||||
|
onClick {
|
||||||
|
ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pref(PreferenceKeys.CLEAR_SAVED_PASS) {
|
||||||
|
fun Preference.updatePref() {
|
||||||
|
val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
|
||||||
|
val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
|
||||||
|
if (sshPass == null && httpsPass == null) {
|
||||||
|
visible = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
httpsPass != null -> titleRes = R.string.clear_saved_passphrase_https
|
||||||
|
sshPass != null -> titleRes = R.string.clear_saved_passphrase_ssh
|
||||||
|
}
|
||||||
|
visible = true
|
||||||
|
requestRebind()
|
||||||
|
}
|
||||||
|
onClick {
|
||||||
|
updatePref()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
updatePref()
|
||||||
|
}
|
||||||
|
pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
|
||||||
|
titleRes = R.string.pref_title_openkeystore_clear_keyid
|
||||||
|
visible = activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() ?: false
|
||||||
|
onClick {
|
||||||
|
activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
|
||||||
|
visible = false
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val deleteRepoPref =
|
||||||
|
pref(PreferenceKeys.GIT_DELETE_REPO) {
|
||||||
|
titleRes = R.string.pref_git_delete_repo_title
|
||||||
|
summaryRes = R.string.pref_git_delete_repo_summary
|
||||||
|
visible = !activity.sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
||||||
|
onClick {
|
||||||
|
val repoDir = PasswordRepository.getRepositoryDirectory()
|
||||||
|
MaterialAlertDialogBuilder(activity)
|
||||||
|
.setTitle(R.string.pref_dialog_delete_title)
|
||||||
|
.setMessage(activity.getString(R.string.dialog_delete_msg, repoDir))
|
||||||
|
.setCancelable(false)
|
||||||
|
.setPositiveButton(R.string.dialog_delete) { dialogInterface, _ ->
|
||||||
|
runCatching {
|
||||||
|
PasswordRepository.getRepositoryDirectory().deleteRecursively()
|
||||||
|
PasswordRepository.closeRepository()
|
||||||
|
}
|
||||||
|
.onFailure { it.message?.let { message -> activity.snackbar(message = message) } }
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||||
|
activity.getSystemService<ShortcutManager>()?.apply {
|
||||||
|
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
|
||||||
|
dialogInterface.cancel()
|
||||||
|
activity.finish()
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ ->
|
||||||
|
run { dialogInterface.cancel() }
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkBox(PreferenceKeys.GIT_EXTERNAL) {
|
||||||
|
titleRes = R.string.pref_external_repository_title
|
||||||
|
summaryRes = R.string.pref_external_repository_summary
|
||||||
|
onCheckedChange { checked ->
|
||||||
|
deleteRepoPref.visible = !checked
|
||||||
|
deleteRepoPref.requestRebind()
|
||||||
|
PasswordRepository.closeRepository()
|
||||||
|
activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pref(PreferenceKeys.GIT_EXTERNAL_REPO) {
|
||||||
|
val externalRepo = activity.sharedPrefs.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||||
|
if (externalRepo != null) {
|
||||||
|
summary = externalRepo
|
||||||
|
} else {
|
||||||
|
summaryRes = R.string.pref_select_external_repository_summary_no_repo_selected
|
||||||
|
}
|
||||||
|
titleRes = R.string.pref_select_external_repository_title
|
||||||
|
dependency = PreferenceKeys.GIT_EXTERNAL
|
||||||
|
onClick {
|
||||||
|
selectExternalGitRepository()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,77 +17,79 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
|
||||||
|
|
||||||
class SettingsActivity : AppCompatActivity() {
|
class SettingsActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private val miscSettings = MiscSettings(this)
|
private val miscSettings = MiscSettings(this)
|
||||||
private val autofillSettings = AutofillSettings(this)
|
private val autofillSettings = AutofillSettings(this)
|
||||||
private val passwordSettings = PasswordSettings(this)
|
private val passwordSettings = PasswordSettings(this)
|
||||||
private val repositorySettings = RepositorySettings(this)
|
private val repositorySettings = RepositorySettings(this)
|
||||||
private val generalSettings = GeneralSettings(this)
|
private val generalSettings = GeneralSettings(this)
|
||||||
|
|
||||||
private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
|
private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
|
||||||
private val preferencesAdapter: PreferencesAdapter
|
private val preferencesAdapter: PreferencesAdapter
|
||||||
get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
|
get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
val screen = screen(this) {
|
val screen =
|
||||||
subScreen {
|
screen(this) {
|
||||||
titleRes = R.string.pref_category_general_title
|
subScreen {
|
||||||
iconRes = R.drawable.app_settings_alt_24px
|
titleRes = R.string.pref_category_general_title
|
||||||
generalSettings.provideSettings(this)
|
iconRes = R.drawable.app_settings_alt_24px
|
||||||
}
|
generalSettings.provideSettings(this)
|
||||||
subScreen {
|
|
||||||
titleRes = R.string.pref_category_autofill_title
|
|
||||||
iconRes = R.drawable.ic_wysiwyg_24px
|
|
||||||
autofillSettings.provideSettings(this)
|
|
||||||
}
|
|
||||||
subScreen {
|
|
||||||
titleRes = R.string.pref_category_passwords_title
|
|
||||||
iconRes = R.drawable.ic_lock_open_24px
|
|
||||||
passwordSettings.provideSettings(this)
|
|
||||||
}
|
|
||||||
subScreen {
|
|
||||||
titleRes = R.string.pref_category_repository_title
|
|
||||||
iconRes = R.drawable.ic_call_merge_24px
|
|
||||||
repositorySettings.provideSettings(this)
|
|
||||||
}
|
|
||||||
subScreen {
|
|
||||||
titleRes = R.string.pref_category_misc_title
|
|
||||||
iconRes = R.drawable.ic_miscellaneous_services_24px
|
|
||||||
miscSettings.provideSettings(this)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val adapter = PreferencesAdapter(screen)
|
subScreen {
|
||||||
adapter.onScreenChangeListener = PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
|
titleRes = R.string.pref_category_autofill_title
|
||||||
supportActionBar?.title = if (!entering) {
|
iconRes = R.drawable.ic_wysiwyg_24px
|
||||||
getString(R.string.action_settings)
|
autofillSettings.provideSettings(this)
|
||||||
} else {
|
|
||||||
getString(subScreen.titleRes)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter")
|
subScreen {
|
||||||
?.let(adapter::loadSavedState)
|
titleRes = R.string.pref_category_passwords_title
|
||||||
binding.preferenceRecyclerView.adapter = adapter
|
iconRes = R.drawable.ic_lock_open_24px
|
||||||
}
|
passwordSettings.provideSettings(this)
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
outState.putParcelable("adapter", preferencesAdapter.getSavedState())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
|
||||||
return when (item.itemId) {
|
|
||||||
android.R.id.home -> if (!preferencesAdapter.goBack()) {
|
|
||||||
super.onOptionsItemSelected(item)
|
|
||||||
} else {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
}
|
||||||
}
|
subScreen {
|
||||||
|
titleRes = R.string.pref_category_repository_title
|
||||||
|
iconRes = R.drawable.ic_call_merge_24px
|
||||||
|
repositorySettings.provideSettings(this)
|
||||||
|
}
|
||||||
|
subScreen {
|
||||||
|
titleRes = R.string.pref_category_misc_title
|
||||||
|
iconRes = R.drawable.ic_miscellaneous_services_24px
|
||||||
|
miscSettings.provideSettings(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val adapter = PreferencesAdapter(screen)
|
||||||
|
adapter.onScreenChangeListener =
|
||||||
|
PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
|
||||||
|
supportActionBar?.title =
|
||||||
|
if (!entering) {
|
||||||
|
getString(R.string.action_settings)
|
||||||
|
} else {
|
||||||
|
getString(subScreen.titleRes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter")?.let(adapter::loadSavedState)
|
||||||
|
binding.preferenceRecyclerView.adapter = adapter
|
||||||
|
}
|
||||||
|
|
||||||
override fun onBackPressed() {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
if (!preferencesAdapter.goBack())
|
super.onSaveInstanceState(outState)
|
||||||
super.onBackPressed()
|
outState.putParcelable("adapter", preferencesAdapter.getSavedState())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
return when (item.itemId) {
|
||||||
|
android.R.id.home ->
|
||||||
|
if (!preferencesAdapter.goBack()) {
|
||||||
|
super.onOptionsItemSelected(item)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBackPressed() {
|
||||||
|
if (!preferencesAdapter.goBack()) super.onBackPressed()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,13 +7,9 @@ package dev.msfjarvis.aps.ui.settings
|
||||||
|
|
||||||
import de.Maxr1998.modernpreferences.PreferenceScreen
|
import de.Maxr1998.modernpreferences.PreferenceScreen
|
||||||
|
|
||||||
/**
|
/** Used to generate a uniform API for all settings UI classes. */
|
||||||
* Used to generate a uniform API for all settings UI classes.
|
|
||||||
*/
|
|
||||||
interface SettingsProvider {
|
interface SettingsProvider {
|
||||||
|
|
||||||
/**
|
/** Inserts the settings items for the class into the given [builder]. */
|
||||||
* Inserts the settings items for the class into the given [builder].
|
fun provideSettings(builder: PreferenceScreen.Builder)
|
||||||
*/
|
|
||||||
fun provideSettings(builder: PreferenceScreen.Builder)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,25 +14,24 @@ import dev.msfjarvis.aps.util.git.sshj.SshKey
|
||||||
|
|
||||||
class ShowSshKeyFragment : DialogFragment() {
|
class ShowSshKeyFragment : DialogFragment() {
|
||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
val activity = requireActivity()
|
val activity = requireActivity()
|
||||||
val publicKey = SshKey.sshPublicKey
|
val publicKey = SshKey.sshPublicKey
|
||||||
return MaterialAlertDialogBuilder(requireActivity()).run {
|
return MaterialAlertDialogBuilder(requireActivity()).run {
|
||||||
setMessage(getString(R.string.ssh_keygen_message, publicKey))
|
setMessage(getString(R.string.ssh_keygen_message, publicKey))
|
||||||
setTitle(R.string.your_public_key)
|
setTitle(R.string.your_public_key)
|
||||||
setNegativeButton(R.string.ssh_keygen_later) { _, _ ->
|
setNegativeButton(R.string.ssh_keygen_later) { _, _ -> (activity as? SshKeyGenActivity)?.finish() }
|
||||||
(activity as? SshKeyGenActivity)?.finish()
|
setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
|
||||||
}
|
val sendIntent =
|
||||||
setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
|
Intent().apply {
|
||||||
val sendIntent = Intent().apply {
|
action = Intent.ACTION_SEND
|
||||||
action = Intent.ACTION_SEND
|
type = "text/plain"
|
||||||
type = "text/plain"
|
putExtra(Intent.EXTRA_TEXT, publicKey)
|
||||||
putExtra(Intent.EXTRA_TEXT, publicKey)
|
}
|
||||||
}
|
startActivity(Intent.createChooser(sendIntent, null))
|
||||||
startActivity(Intent.createChooser(sendIntent, null))
|
(activity as? SshKeyGenActivity)?.finish()
|
||||||
(activity as? SshKeyGenActivity)?.finish()
|
}
|
||||||
}
|
create()
|
||||||
create()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,135 +30,122 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) {
|
private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) {
|
||||||
Rsa({ requireAuthentication ->
|
Rsa({ requireAuthentication -> SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) }),
|
||||||
SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication)
|
Ecdsa({ requireAuthentication -> SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) }),
|
||||||
}),
|
Ed25519({ requireAuthentication -> SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) }),
|
||||||
Ecdsa({ requireAuthentication ->
|
|
||||||
SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication)
|
|
||||||
}),
|
|
||||||
Ed25519({ requireAuthentication ->
|
|
||||||
SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication)
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class SshKeyGenActivity : AppCompatActivity() {
|
class SshKeyGenActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private var keyGenType = KeyGenType.Ecdsa
|
private var keyGenType = KeyGenType.Ecdsa
|
||||||
private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
|
private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
with(binding) {
|
with(binding) {
|
||||||
generate.setOnClickListener {
|
generate.setOnClickListener {
|
||||||
if (SshKey.exists) {
|
if (SshKey.exists) {
|
||||||
MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
|
MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
|
||||||
setTitle(R.string.ssh_keygen_existing_title)
|
setTitle(R.string.ssh_keygen_existing_title)
|
||||||
setMessage(R.string.ssh_keygen_existing_message)
|
setMessage(R.string.ssh_keygen_existing_message)
|
||||||
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
|
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> lifecycleScope.launch { generate() } }
|
||||||
lifecycleScope.launch {
|
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
|
||||||
generate()
|
show()
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ ->
|
lifecycleScope.launch { generate() }
|
||||||
finish()
|
|
||||||
}
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
lifecycleScope.launch {
|
|
||||||
generate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keyTypeGroup.check(R.id.key_type_ecdsa)
|
|
||||||
keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
|
|
||||||
keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
|
||||||
if (isChecked) {
|
|
||||||
keyGenType = when (checkedId) {
|
|
||||||
R.id.key_type_ed25519 -> KeyGenType.Ed25519
|
|
||||||
R.id.key_type_ecdsa -> KeyGenType.Ecdsa
|
|
||||||
R.id.key_type_rsa -> KeyGenType.Rsa
|
|
||||||
else -> throw IllegalStateException("Impossible key type selection")
|
|
||||||
}
|
|
||||||
keyTypeExplanation.setText(when (keyGenType) {
|
|
||||||
KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
|
|
||||||
KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
|
|
||||||
KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure
|
|
||||||
keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
keyTypeGroup.check(R.id.key_type_ecdsa)
|
||||||
|
keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
|
||||||
|
keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (isChecked) {
|
||||||
|
keyGenType =
|
||||||
|
when (checkedId) {
|
||||||
|
R.id.key_type_ed25519 -> KeyGenType.Ed25519
|
||||||
|
R.id.key_type_ecdsa -> KeyGenType.Ecdsa
|
||||||
|
R.id.key_type_rsa -> KeyGenType.Rsa
|
||||||
|
else -> throw IllegalStateException("Impossible key type selection")
|
||||||
|
}
|
||||||
|
keyTypeExplanation.setText(
|
||||||
|
when (keyGenType) {
|
||||||
|
KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
|
||||||
|
KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
|
||||||
|
KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure
|
||||||
|
keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
android.R.id.home -> {
|
android.R.id.home -> {
|
||||||
onBackPressed()
|
onBackPressed()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
else -> super.onOptionsItemSelected(item)
|
else -> super.onOptionsItemSelected(item)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun generate() {
|
private suspend fun generate() {
|
||||||
binding.generate.apply {
|
binding.generate.apply {
|
||||||
text = getString(R.string.ssh_key_gen_generating_progress)
|
text = getString(R.string.ssh_key_gen_generating_progress)
|
||||||
isEnabled = false
|
isEnabled = false
|
||||||
}
|
}
|
||||||
binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
|
binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val requireAuthentication = binding.keyRequireAuthentication.isChecked
|
val requireAuthentication = binding.keyRequireAuthentication.isChecked
|
||||||
if (requireAuthentication) {
|
if (requireAuthentication) {
|
||||||
val result = withContext(Dispatchers.Main) {
|
val result =
|
||||||
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
|
withContext(Dispatchers.Main) {
|
||||||
BiometricAuthenticator.authenticate(this@SshKeyGenActivity, R.string.biometric_prompt_title_ssh_keygen) {
|
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
|
||||||
cont.resume(it)
|
BiometricAuthenticator.authenticate(
|
||||||
}
|
this@SshKeyGenActivity,
|
||||||
}
|
R.string.biometric_prompt_title_ssh_keygen
|
||||||
}
|
) { cont.resume(it) }
|
||||||
if (result !is BiometricAuthenticator.Result.Success)
|
}
|
||||||
throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure))
|
|
||||||
}
|
|
||||||
keyGenType.generateKey(requireAuthentication)
|
|
||||||
}
|
}
|
||||||
|
if (result !is BiometricAuthenticator.Result.Success)
|
||||||
|
throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure))
|
||||||
}
|
}
|
||||||
getEncryptedGitPrefs().edit {
|
keyGenType.generateKey(requireAuthentication)
|
||||||
remove("ssh_key_local_passphrase")
|
}
|
||||||
}
|
|
||||||
binding.generate.apply {
|
|
||||||
text = getString(R.string.ssh_keygen_generate)
|
|
||||||
isEnabled = true
|
|
||||||
}
|
|
||||||
result.fold(
|
|
||||||
success = {
|
|
||||||
ShowSshKeyFragment().show(supportFragmentManager, "public_key")
|
|
||||||
},
|
|
||||||
failure = { e ->
|
|
||||||
e.printStackTrace()
|
|
||||||
MaterialAlertDialogBuilder(this)
|
|
||||||
.setTitle(getString(R.string.error_generate_ssh_key))
|
|
||||||
.setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message)
|
|
||||||
.setPositiveButton(getString(R.string.dialog_ok)) { _, _ ->
|
|
||||||
setResult(RESULT_OK)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
hideKeyboard()
|
|
||||||
}
|
}
|
||||||
|
getEncryptedGitPrefs().edit { remove("ssh_key_local_passphrase") }
|
||||||
|
binding.generate.apply {
|
||||||
|
text = getString(R.string.ssh_keygen_generate)
|
||||||
|
isEnabled = true
|
||||||
|
}
|
||||||
|
result.fold(
|
||||||
|
success = { ShowSshKeyFragment().show(supportFragmentManager, "public_key") },
|
||||||
|
failure = { e ->
|
||||||
|
e.printStackTrace()
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(getString(R.string.error_generate_ssh_key))
|
||||||
|
.setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message)
|
||||||
|
.setPositiveButton(getString(R.string.dialog_ok)) { _, _ ->
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
|
|
||||||
private fun hideKeyboard() {
|
private fun hideKeyboard() {
|
||||||
val imm = getSystemService<InputMethodManager>() ?: return
|
val imm = getSystemService<InputMethodManager>() ?: return
|
||||||
var view = currentFocus
|
var view = currentFocus
|
||||||
if (view == null) {
|
if (view == null) {
|
||||||
view = View(this)
|
view = View(this)
|
||||||
}
|
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
|
||||||
}
|
}
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,44 +18,44 @@ import dev.msfjarvis.aps.util.git.sshj.SshKey
|
||||||
|
|
||||||
class SshKeyImportActivity : AppCompatActivity() {
|
class SshKeyImportActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private val sshKeyImportAction = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
|
private val sshKeyImportAction =
|
||||||
if (uri == null) {
|
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
|
||||||
finish()
|
if (uri == null) {
|
||||||
return@registerForActivityResult
|
finish()
|
||||||
}
|
return@registerForActivityResult
|
||||||
runCatching {
|
}
|
||||||
SshKey.import(uri)
|
runCatching {
|
||||||
Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show()
|
SshKey.import(uri)
|
||||||
setResult(RESULT_OK)
|
Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show()
|
||||||
finish()
|
setResult(RESULT_OK)
|
||||||
}.onFailure { e ->
|
finish()
|
||||||
MaterialAlertDialogBuilder(this)
|
}
|
||||||
.setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
|
.onFailure { e ->
|
||||||
.setMessage(e.message)
|
MaterialAlertDialogBuilder(this)
|
||||||
.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() }
|
.setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
|
||||||
.show()
|
.setMessage(e.message)
|
||||||
|
.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() }
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
if (SshKey.exists) {
|
if (SshKey.exists) {
|
||||||
MaterialAlertDialogBuilder(this).run {
|
MaterialAlertDialogBuilder(this).run {
|
||||||
setTitle(R.string.ssh_keygen_existing_title)
|
setTitle(R.string.ssh_keygen_existing_title)
|
||||||
setMessage(R.string.ssh_keygen_existing_message)
|
setMessage(R.string.ssh_keygen_existing_message)
|
||||||
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
|
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() }
|
||||||
importSshKey()
|
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
|
||||||
}
|
setOnCancelListener { finish() }
|
||||||
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
|
show()
|
||||||
setOnCancelListener { finish() }
|
}
|
||||||
show()
|
} else {
|
||||||
}
|
importSshKey()
|
||||||
} else {
|
|
||||||
importSshKey()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun importSshKey() {
|
private fun importSshKey() {
|
||||||
sshKeyImportAction.launch(arrayOf("*/*"))
|
sshKeyImportAction.launch(arrayOf("*/*"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,63 +9,63 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
|
|
||||||
class OnOffItemAnimator : DefaultItemAnimator() {
|
class OnOffItemAnimator : DefaultItemAnimator() {
|
||||||
|
|
||||||
var isEnabled: Boolean = true
|
var isEnabled: Boolean = true
|
||||||
set(value) {
|
set(value) {
|
||||||
// Defer update until no animation is running anymore.
|
// Defer update until no animation is running anymore.
|
||||||
isRunning { field = value }
|
isRunning { field = value }
|
||||||
}
|
|
||||||
|
|
||||||
private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean {
|
|
||||||
dispatchAnimationFinished(viewHolder)
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun animateAppearance(
|
private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean {
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
dispatchAnimationFinished(viewHolder)
|
||||||
preLayoutInfo: ItemHolderInfo?,
|
return false
|
||||||
postLayoutInfo: ItemHolderInfo
|
}
|
||||||
): Boolean {
|
|
||||||
return if (isEnabled) {
|
|
||||||
super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo)
|
|
||||||
} else {
|
|
||||||
dontAnimate(viewHolder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun animateChange(
|
override fun animateAppearance(
|
||||||
oldHolder: RecyclerView.ViewHolder,
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
newHolder: RecyclerView.ViewHolder,
|
preLayoutInfo: ItemHolderInfo?,
|
||||||
preInfo: ItemHolderInfo,
|
postLayoutInfo: ItemHolderInfo
|
||||||
postInfo: ItemHolderInfo
|
): Boolean {
|
||||||
): Boolean {
|
return if (isEnabled) {
|
||||||
return if (isEnabled) {
|
super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo)
|
||||||
super.animateChange(oldHolder, newHolder, preInfo, postInfo)
|
} else {
|
||||||
} else {
|
dontAnimate(viewHolder)
|
||||||
dontAnimate(oldHolder)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun animateDisappearance(
|
override fun animateChange(
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
oldHolder: RecyclerView.ViewHolder,
|
||||||
preLayoutInfo: ItemHolderInfo,
|
newHolder: RecyclerView.ViewHolder,
|
||||||
postLayoutInfo: ItemHolderInfo?
|
preInfo: ItemHolderInfo,
|
||||||
): Boolean {
|
postInfo: ItemHolderInfo
|
||||||
return if (isEnabled) {
|
): Boolean {
|
||||||
super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo)
|
return if (isEnabled) {
|
||||||
} else {
|
super.animateChange(oldHolder, newHolder, preInfo, postInfo)
|
||||||
dontAnimate(viewHolder)
|
} else {
|
||||||
}
|
dontAnimate(oldHolder)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun animatePersistence(
|
override fun animateDisappearance(
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
preInfo: ItemHolderInfo,
|
preLayoutInfo: ItemHolderInfo,
|
||||||
postInfo: ItemHolderInfo
|
postLayoutInfo: ItemHolderInfo?
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return if (isEnabled) {
|
return if (isEnabled) {
|
||||||
super.animatePersistence(viewHolder, preInfo, postInfo)
|
super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo)
|
||||||
} else {
|
} else {
|
||||||
dontAnimate(viewHolder)
|
dontAnimate(viewHolder)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun animatePersistence(
|
||||||
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
|
preInfo: ItemHolderInfo,
|
||||||
|
postInfo: ItemHolderInfo
|
||||||
|
): Boolean {
|
||||||
|
return if (isEnabled) {
|
||||||
|
super.animatePersistence(viewHolder, preInfo, postInfo)
|
||||||
|
} else {
|
||||||
|
dontAnimate(viewHolder)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,61 +18,69 @@ import dev.msfjarvis.aps.R
|
||||||
|
|
||||||
object BiometricAuthenticator {
|
object BiometricAuthenticator {
|
||||||
|
|
||||||
private const val TAG = "BiometricAuthenticator"
|
private const val TAG = "BiometricAuthenticator"
|
||||||
private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
|
private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
|
||||||
|
|
||||||
sealed class Result {
|
sealed class Result {
|
||||||
data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
|
data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
|
||||||
data class Failure(val code: Int?, val message: CharSequence) : Result()
|
data class Failure(val code: Int?, val message: CharSequence) : Result()
|
||||||
object HardwareUnavailableOrDisabled : Result()
|
object HardwareUnavailableOrDisabled : Result()
|
||||||
object Cancelled : Result()
|
object Cancelled : Result()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun canAuthenticate(activity: FragmentActivity): Boolean {
|
fun canAuthenticate(activity: FragmentActivity): Boolean {
|
||||||
return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
|
return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
fun authenticate(
|
fun authenticate(
|
||||||
activity: FragmentActivity,
|
activity: FragmentActivity,
|
||||||
@StringRes dialogTitleRes: Int = R.string.biometric_prompt_title,
|
@StringRes dialogTitleRes: Int = R.string.biometric_prompt_title,
|
||||||
callback: (Result) -> Unit
|
callback: (Result) -> Unit
|
||||||
) {
|
) {
|
||||||
val authCallback = object : BiometricPrompt.AuthenticationCallback() {
|
val authCallback =
|
||||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
super.onAuthenticationError(errorCode, errString)
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" }
|
super.onAuthenticationError(errorCode, errString)
|
||||||
callback(when (errorCode) {
|
tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" }
|
||||||
BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED,
|
callback(
|
||||||
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
|
when (errorCode) {
|
||||||
Result.Cancelled
|
BiometricPrompt.ERROR_CANCELED,
|
||||||
}
|
BiometricPrompt.ERROR_USER_CANCELED,
|
||||||
BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE,
|
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
|
||||||
BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
|
Result.Cancelled
|
||||||
Result.HardwareUnavailableOrDisabled
|
}
|
||||||
}
|
BiometricPrompt.ERROR_HW_NOT_PRESENT,
|
||||||
else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString))
|
BiometricPrompt.ERROR_HW_UNAVAILABLE,
|
||||||
})
|
BiometricPrompt.ERROR_NO_BIOMETRICS,
|
||||||
}
|
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
|
||||||
|
Result.HardwareUnavailableOrDisabled
|
||||||
override fun onAuthenticationFailed() {
|
}
|
||||||
super.onAuthenticationFailed()
|
else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString))
|
||||||
callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error)))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
||||||
super.onAuthenticationSucceeded(result)
|
|
||||||
callback(Result.Success(result.cryptoObject))
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true
|
|
||||||
if (canAuthenticate(activity) || deviceHasKeyguard) {
|
override fun onAuthenticationFailed() {
|
||||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
super.onAuthenticationFailed()
|
||||||
.setTitle(activity.getString(dialogTitleRes))
|
callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error)))
|
||||||
.setAllowedAuthenticators(validAuthenticators)
|
|
||||||
.build()
|
|
||||||
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback).authenticate(promptInfo)
|
|
||||||
} else {
|
|
||||||
callback(Result.HardwareUnavailableOrDisabled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
super.onAuthenticationSucceeded(result)
|
||||||
|
callback(Result.Success(result.cryptoObject))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true
|
||||||
|
if (canAuthenticate(activity) || deviceHasKeyguard) {
|
||||||
|
val promptInfo =
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(activity.getString(dialogTitleRes))
|
||||||
|
.setAllowedAuthenticators(validAuthenticators)
|
||||||
|
.build()
|
||||||
|
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback)
|
||||||
|
.authenticate(promptInfo)
|
||||||
|
} else {
|
||||||
|
callback(Result.HardwareUnavailableOrDisabled)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,163 +27,166 @@ import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
|
||||||
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
|
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
|
||||||
* Implements [AutofillResponseBuilder]'s methods for API 30 and above
|
|
||||||
*/
|
|
||||||
@RequiresApi(Build.VERSION_CODES.R)
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
class Api30AutofillResponseBuilder(form: FillableForm) {
|
class Api30AutofillResponseBuilder(form: FillableForm) {
|
||||||
|
|
||||||
private val formOrigin = form.formOrigin
|
private val formOrigin = form.formOrigin
|
||||||
private val scenario = form.scenario
|
private val scenario = form.scenario
|
||||||
private val ignoredIds = form.ignoredIds
|
private val ignoredIds = form.ignoredIds
|
||||||
private val saveFlags = form.saveFlags
|
private val saveFlags = form.saveFlags
|
||||||
private val clientState = form.toClientState()
|
private val clientState = form.toClientState()
|
||||||
|
|
||||||
// We do not offer save when the only relevant field is a username field or there is no field.
|
// We do not offer save when the only relevant field is a username field or there is no field.
|
||||||
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
|
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
|
||||||
private val canBeSaved = saveFlags != null && scenarioSupportsSave
|
private val canBeSaved = saveFlags != null && scenarioSupportsSave
|
||||||
|
|
||||||
private fun makeIntentDataset(
|
private fun makeIntentDataset(
|
||||||
context: Context,
|
context: Context,
|
||||||
action: AutofillAction,
|
action: AutofillAction,
|
||||||
intentSender: IntentSender,
|
intentSender: IntentSender,
|
||||||
metadata: DatasetMetadata,
|
metadata: DatasetMetadata,
|
||||||
imeSpec: InlinePresentationSpec?,
|
imeSpec: InlinePresentationSpec?,
|
||||||
): Dataset {
|
): Dataset {
|
||||||
return Dataset.Builder(makeRemoteView(context, metadata)).run {
|
return Dataset.Builder(makeRemoteView(context, metadata)).run {
|
||||||
fillWith(scenario, action, credentials = null)
|
fillWith(scenario, action, credentials = null)
|
||||||
setAuthentication(intentSender)
|
setAuthentication(intentSender)
|
||||||
if (imeSpec != null) {
|
if (imeSpec != null) {
|
||||||
val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata)
|
val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata)
|
||||||
if (inlinePresentation != null) {
|
if (inlinePresentation != null) {
|
||||||
setInlinePresentation(inlinePresentation)
|
setInlinePresentation(inlinePresentation)
|
||||||
}
|
|
||||||
}
|
|
||||||
build()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
build()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? {
|
private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
||||||
val metadata = makeFillMatchMetadata(context, file)
|
val metadata = makeFillMatchMetadata(context, file)
|
||||||
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
||||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
|
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||||
|
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
|
||||||
|
val metadata = makeSearchAndFillMetadata(context)
|
||||||
|
val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
|
||||||
|
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||||
|
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
|
||||||
|
val metadata = makeGenerateAndFillMetadata(context)
|
||||||
|
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
|
||||||
|
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||||
|
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
|
||||||
|
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
|
||||||
|
val metadata = makeFillOtpFromSmsMetadata(context)
|
||||||
|
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
|
||||||
|
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makePublisherChangedDataset(
|
||||||
|
context: Context,
|
||||||
|
publisherChangedException: AutofillPublisherChangedException,
|
||||||
|
imeSpec: InlinePresentationSpec?
|
||||||
|
): Dataset {
|
||||||
|
val metadata = makeWarningMetadata(context)
|
||||||
|
// If the user decides to trust the new publisher, they can choose reset the list of
|
||||||
|
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
|
||||||
|
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
|
||||||
|
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
|
||||||
|
val fillResponseAfterReset = makeFillResponse(context, null, emptyList())
|
||||||
|
val intentSender =
|
||||||
|
AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
|
||||||
|
context,
|
||||||
|
publisherChangedException,
|
||||||
|
fillResponseAfterReset
|
||||||
|
)
|
||||||
|
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makePublisherChangedResponse(
|
||||||
|
context: Context,
|
||||||
|
inlineSuggestionsRequest: InlineSuggestionsRequest?,
|
||||||
|
publisherChangedException: AutofillPublisherChangedException
|
||||||
|
): FillResponse {
|
||||||
|
val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull()
|
||||||
|
return FillResponse.Builder().run {
|
||||||
|
addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec))
|
||||||
|
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||||
|
build()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
private fun makeFillResponse(
|
||||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
|
context: Context,
|
||||||
val metadata = makeSearchAndFillMetadata(context)
|
inlineSuggestionsRequest: InlineSuggestionsRequest?,
|
||||||
val intentSender =
|
matchedFiles: List<File>
|
||||||
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
|
): FillResponse? {
|
||||||
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec)
|
var datasetCount = 0
|
||||||
}
|
val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
|
||||||
|
return FillResponse.Builder().run {
|
||||||
private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
for (file in matchedFiles) {
|
||||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
|
makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
|
||||||
val metadata = makeGenerateAndFillMetadata(context)
|
datasetCount++
|
||||||
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
|
addDataset(it)
|
||||||
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
|
||||||
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
|
|
||||||
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
|
|
||||||
val metadata = makeFillOtpFromSmsMetadata(context)
|
|
||||||
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
|
|
||||||
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makePublisherChangedDataset(
|
|
||||||
context: Context,
|
|
||||||
publisherChangedException: AutofillPublisherChangedException,
|
|
||||||
imeSpec: InlinePresentationSpec?
|
|
||||||
): Dataset {
|
|
||||||
val metadata = makeWarningMetadata(context)
|
|
||||||
// If the user decides to trust the new publisher, they can choose reset the list of
|
|
||||||
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
|
|
||||||
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
|
|
||||||
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
|
|
||||||
val fillResponseAfterReset = makeFillResponse(context, null, emptyList())
|
|
||||||
val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
|
|
||||||
context, publisherChangedException, fillResponseAfterReset
|
|
||||||
)
|
|
||||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makePublisherChangedResponse(
|
|
||||||
context: Context,
|
|
||||||
inlineSuggestionsRequest: InlineSuggestionsRequest?,
|
|
||||||
publisherChangedException: AutofillPublisherChangedException
|
|
||||||
): FillResponse {
|
|
||||||
val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull()
|
|
||||||
return FillResponse.Builder().run {
|
|
||||||
addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec))
|
|
||||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
|
||||||
build()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
||||||
|
datasetCount++
|
||||||
|
addDataset(it)
|
||||||
|
}
|
||||||
|
makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
||||||
|
datasetCount++
|
||||||
|
addDataset(it)
|
||||||
|
}
|
||||||
|
makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
||||||
|
datasetCount++
|
||||||
|
addDataset(it)
|
||||||
|
}
|
||||||
|
if (datasetCount == 0) return null
|
||||||
|
setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
|
||||||
|
makeSaveInfo()?.let { setSaveInfo(it) }
|
||||||
|
setClientState(clientState)
|
||||||
|
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||||
|
build()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List<File>): FillResponse? {
|
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
|
||||||
var datasetCount = 0
|
// See:
|
||||||
val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
|
// https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
|
||||||
return FillResponse.Builder().run {
|
private fun makeSaveInfo(): SaveInfo? {
|
||||||
for (file in matchedFiles) {
|
if (!canBeSaved) return null
|
||||||
makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
|
check(saveFlags != null)
|
||||||
datasetCount++
|
val idsToSave = scenario.fieldsToSave.toTypedArray()
|
||||||
addDataset(it)
|
if (idsToSave.isEmpty()) return null
|
||||||
}
|
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
|
||||||
}
|
if (scenario.hasUsername) {
|
||||||
makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
|
||||||
datasetCount++
|
|
||||||
addDataset(it)
|
|
||||||
}
|
|
||||||
makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
|
||||||
datasetCount++
|
|
||||||
addDataset(it)
|
|
||||||
}
|
|
||||||
makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
|
||||||
datasetCount++
|
|
||||||
addDataset(it)
|
|
||||||
}
|
|
||||||
if (datasetCount == 0) return null
|
|
||||||
setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
|
|
||||||
makeSaveInfo()?.let { setSaveInfo(it) }
|
|
||||||
setClientState(clientState)
|
|
||||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
|
||||||
build()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
|
||||||
|
setFlags(saveFlags)
|
||||||
|
build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
|
/** Creates and returns a suitable [FillResponse] to the Autofill framework. */
|
||||||
// See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
|
fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) {
|
||||||
private fun makeSaveInfo(): SaveInfo? {
|
AutofillMatcher.getMatchesFor(context, formOrigin)
|
||||||
if (!canBeSaved) return null
|
.fold(
|
||||||
check(saveFlags != null)
|
success = { matchedFiles ->
|
||||||
val idsToSave = scenario.fieldsToSave.toTypedArray()
|
callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
|
||||||
if (idsToSave.isEmpty()) return null
|
},
|
||||||
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
|
failure = { e ->
|
||||||
if (scenario.hasUsername) {
|
e(e)
|
||||||
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
|
callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e))
|
||||||
}
|
}
|
||||||
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
|
)
|
||||||
setFlags(saveFlags)
|
}
|
||||||
build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and returns a suitable [FillResponse] to the Autofill framework.
|
|
||||||
*/
|
|
||||||
fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) {
|
|
||||||
AutofillMatcher.getMatchesFor(context, formOrigin).fold(
|
|
||||||
success = { matchedFiles ->
|
|
||||||
callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
|
|
||||||
},
|
|
||||||
failure = { e ->
|
|
||||||
e(e)
|
|
||||||
callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,173 +21,165 @@ import java.io.File
|
||||||
|
|
||||||
private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches"
|
private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches"
|
||||||
private val Context.autofillAppMatches
|
private val Context.autofillAppMatches
|
||||||
get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
|
get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches"
|
private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches"
|
||||||
private val Context.autofillWebMatches
|
private val Context.autofillWebMatches
|
||||||
get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
|
get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences {
|
private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences {
|
||||||
return when (formOrigin) {
|
return when (formOrigin) {
|
||||||
is FormOrigin.App -> autofillAppMatches
|
is FormOrigin.App -> autofillAppMatches
|
||||||
is FormOrigin.Web -> autofillWebMatches
|
is FormOrigin.Web -> autofillWebMatches
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
|
class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
|
||||||
Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
|
Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
require(formOrigin is FormOrigin.App)
|
require(formOrigin is FormOrigin.App)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Manages "matches", i.e., associations between apps or websites and Password Store entries. */
|
||||||
* Manages "matches", i.e., associations between apps or websites and Password Store entries.
|
|
||||||
*/
|
|
||||||
class AutofillMatcher {
|
class AutofillMatcher {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val MAX_NUM_MATCHES = 10
|
private const val MAX_NUM_MATCHES = 10
|
||||||
|
|
||||||
private const val PREFERENCE_PREFIX_TOKEN = "token;"
|
private const val PREFERENCE_PREFIX_TOKEN = "token;"
|
||||||
private fun tokenKey(formOrigin: FormOrigin.App) =
|
private fun tokenKey(formOrigin: FormOrigin.App) = "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
|
||||||
"$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
|
|
||||||
|
|
||||||
private const val PREFERENCE_PREFIX_MATCHES = "matches;"
|
private const val PREFERENCE_PREFIX_MATCHES = "matches;"
|
||||||
private fun matchesKey(formOrigin: FormOrigin) =
|
private fun matchesKey(formOrigin: FormOrigin) = "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
|
||||||
"$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
|
|
||||||
|
|
||||||
private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
|
private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
|
||||||
return when (formOrigin) {
|
return when (formOrigin) {
|
||||||
is FormOrigin.Web -> false
|
is FormOrigin.Web -> false
|
||||||
is FormOrigin.App -> {
|
is FormOrigin.App -> {
|
||||||
val packageName = formOrigin.identifier
|
val packageName = formOrigin.identifier
|
||||||
val certificatesHash = computeCertificatesHash(context, packageName)
|
val certificatesHash = computeCertificatesHash(context, packageName)
|
||||||
val storedCertificatesHash =
|
val storedCertificatesHash = context.autofillAppMatches.getString(tokenKey(formOrigin), null) ?: return false
|
||||||
context.autofillAppMatches.getString(tokenKey(formOrigin), null)
|
val hashHasChanged = certificatesHash != storedCertificatesHash
|
||||||
?: return false
|
if (hashHasChanged) {
|
||||||
val hashHasChanged = certificatesHash != storedCertificatesHash
|
e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
|
||||||
if (hashHasChanged) {
|
true
|
||||||
e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
|
} else {
|
||||||
true
|
false
|
||||||
} else {
|
}
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
|
|
||||||
if (formOrigin is FormOrigin.App) {
|
|
||||||
val packageName = formOrigin.identifier
|
|
||||||
val certificatesHash = computeCertificatesHash(context, packageName)
|
|
||||||
context.autofillAppMatches.edit {
|
|
||||||
putString(tokenKey(formOrigin), certificatesHash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// We don't need to store a hash for FormOrigin.Web since it can only originate from
|
|
||||||
// browsers we trust to verify the origin.
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all Password Store entries that have already been associated with [formOrigin] by the
|
|
||||||
* user.
|
|
||||||
*
|
|
||||||
* If [formOrigin] represents an app and that app's certificates have changed since the
|
|
||||||
* first time the user associated an entry with it, an [AutofillPublisherChangedException]
|
|
||||||
* will be thrown.
|
|
||||||
*/
|
|
||||||
fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> {
|
|
||||||
if (hasFormOriginHashChanged(context, formOrigin)) {
|
|
||||||
return Err(AutofillPublisherChangedException(formOrigin))
|
|
||||||
}
|
|
||||||
val matchPreferences = context.matchPreferences(formOrigin)
|
|
||||||
val matchedFiles =
|
|
||||||
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
|
|
||||||
return Ok(matchedFiles.filter { it.exists() }.also { validFiles ->
|
|
||||||
matchPreferences.edit {
|
|
||||||
putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
|
|
||||||
context.matchPreferences(formOrigin).edit {
|
|
||||||
remove(matchesKey(formOrigin))
|
|
||||||
if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Associates the store entry [file] with [formOrigin], such that future Autofill responses
|
|
||||||
* to requests from this app or website offer this entry as a dataset.
|
|
||||||
*
|
|
||||||
* The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of
|
|
||||||
* Android may crash when too many datasets are offered.
|
|
||||||
*/
|
|
||||||
fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
|
|
||||||
if (!file.exists()) return
|
|
||||||
if (hasFormOriginHashChanged(context, formOrigin)) {
|
|
||||||
// This should never happen since we already verified the publisher in
|
|
||||||
// getMatchesFor.
|
|
||||||
e { "App publisher changed between getMatchesFor and addMatchFor" }
|
|
||||||
throw AutofillPublisherChangedException(formOrigin)
|
|
||||||
}
|
|
||||||
val matchPreferences = context.matchPreferences(formOrigin)
|
|
||||||
val matchedFiles =
|
|
||||||
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
|
|
||||||
val newFiles = setOf(file.absoluteFile).union(matchedFiles)
|
|
||||||
if (newFiles.size > MAX_NUM_MATCHES) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
|
|
||||||
Toast.LENGTH_LONG
|
|
||||||
).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
matchPreferences.edit {
|
|
||||||
putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet())
|
|
||||||
}
|
|
||||||
storeFormOriginHash(context, formOrigin)
|
|
||||||
d { "Stored match for $formOrigin" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Goes through all existing matches and updates their associated entries by using
|
|
||||||
* [moveFromTo] as a lookup table and deleting the matches for files in [delete].
|
|
||||||
*/
|
|
||||||
fun updateMatches(context: Context, moveFromTo: Map<File, File> = emptyMap(), delete: Collection<File> = emptyList()) {
|
|
||||||
val deletePathList = delete.map { it.absolutePath }
|
|
||||||
val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }
|
|
||||||
.mapKeys { it.key.absolutePath }
|
|
||||||
for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
|
|
||||||
for ((key, value) in prefs.all) {
|
|
||||||
if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
|
|
||||||
// We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
|
|
||||||
// created with `putStringSet`.
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val oldMatches = value as? Set<String>
|
|
||||||
if (oldMatches == null) {
|
|
||||||
w { "Failed to read matches for $key" }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Delete all matches for file locations that are going to be overwritten, then
|
|
||||||
// transfer matches over to the files at their new locations.
|
|
||||||
val newMatches =
|
|
||||||
oldMatches.asSequence()
|
|
||||||
.minus(deletePathList)
|
|
||||||
.minus(oldNewPathMap.values)
|
|
||||||
.map { match ->
|
|
||||||
val newPath = oldNewPathMap[match] ?: return@map match
|
|
||||||
d { "Updating match for $key: $match --> $newPath" }
|
|
||||||
newPath
|
|
||||||
}.toSet()
|
|
||||||
if (newMatches != oldMatches)
|
|
||||||
prefs.edit { putStringSet(key, newMatches) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
|
||||||
|
if (formOrigin is FormOrigin.App) {
|
||||||
|
val packageName = formOrigin.identifier
|
||||||
|
val certificatesHash = computeCertificatesHash(context, packageName)
|
||||||
|
context.autofillAppMatches.edit { putString(tokenKey(formOrigin), certificatesHash) }
|
||||||
|
}
|
||||||
|
// We don't need to store a hash for FormOrigin.Web since it can only originate from
|
||||||
|
// browsers we trust to verify the origin.
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all Password Store entries that have already been associated with [formOrigin] by the
|
||||||
|
* user.
|
||||||
|
*
|
||||||
|
* If [formOrigin] represents an app and that app's certificates have changed since the first
|
||||||
|
* time the user associated an entry with it, an [AutofillPublisherChangedException] will be
|
||||||
|
* thrown.
|
||||||
|
*/
|
||||||
|
fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> {
|
||||||
|
if (hasFormOriginHashChanged(context, formOrigin)) {
|
||||||
|
return Err(AutofillPublisherChangedException(formOrigin))
|
||||||
|
}
|
||||||
|
val matchPreferences = context.matchPreferences(formOrigin)
|
||||||
|
val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
|
||||||
|
return Ok(
|
||||||
|
matchedFiles.filter { it.exists() }.also { validFiles ->
|
||||||
|
matchPreferences.edit { putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
|
||||||
|
context.matchPreferences(formOrigin).edit {
|
||||||
|
remove(matchesKey(formOrigin))
|
||||||
|
if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Associates the store entry [file] with [formOrigin], such that future Autofill responses to
|
||||||
|
* requests from this app or website offer this entry as a dataset.
|
||||||
|
*
|
||||||
|
* The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of Android
|
||||||
|
* may crash when too many datasets are offered.
|
||||||
|
*/
|
||||||
|
fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
|
||||||
|
if (!file.exists()) return
|
||||||
|
if (hasFormOriginHashChanged(context, formOrigin)) {
|
||||||
|
// This should never happen since we already verified the publisher in
|
||||||
|
// getMatchesFor.
|
||||||
|
e { "App publisher changed between getMatchesFor and addMatchFor" }
|
||||||
|
throw AutofillPublisherChangedException(formOrigin)
|
||||||
|
}
|
||||||
|
val matchPreferences = context.matchPreferences(formOrigin)
|
||||||
|
val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
|
||||||
|
val newFiles = setOf(file.absoluteFile).union(matchedFiles)
|
||||||
|
if (newFiles.size > MAX_NUM_MATCHES) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
matchPreferences.edit { putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) }
|
||||||
|
storeFormOriginHash(context, formOrigin)
|
||||||
|
d { "Stored match for $formOrigin" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Goes through all existing matches and updates their associated entries by using [moveFromTo]
|
||||||
|
* as a lookup table and deleting the matches for files in [delete].
|
||||||
|
*/
|
||||||
|
fun updateMatches(
|
||||||
|
context: Context,
|
||||||
|
moveFromTo: Map<File, File> = emptyMap(),
|
||||||
|
delete: Collection<File> = emptyList()
|
||||||
|
) {
|
||||||
|
val deletePathList = delete.map { it.absolutePath }
|
||||||
|
val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }.mapKeys { it.key.absolutePath }
|
||||||
|
for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
|
||||||
|
for ((key, value) in prefs.all) {
|
||||||
|
if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
|
||||||
|
// We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
|
||||||
|
// created with `putStringSet`.
|
||||||
|
@Suppress("UNCHECKED_CAST") val oldMatches = value as? Set<String>
|
||||||
|
if (oldMatches == null) {
|
||||||
|
w { "Failed to read matches for $key" }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Delete all matches for file locations that are going to be overwritten, then
|
||||||
|
// transfer matches over to the files at their new locations.
|
||||||
|
val newMatches =
|
||||||
|
oldMatches
|
||||||
|
.asSequence()
|
||||||
|
.minus(deletePathList)
|
||||||
|
.minus(oldNewPathMap.values)
|
||||||
|
.map { match ->
|
||||||
|
val newPath = oldNewPathMap[match] ?: return@map match
|
||||||
|
d { "Updating match for $key: $match --> $newPath" }
|
||||||
|
newPath
|
||||||
|
}
|
||||||
|
.toSet()
|
||||||
|
if (newMatches != oldMatches) prefs.edit { putStringSet(key, newMatches) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,125 +17,128 @@ import java.io.File
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
|
||||||
enum class DirectoryStructure(val value: String) {
|
enum class DirectoryStructure(val value: String) {
|
||||||
EncryptedUsername("encrypted_username"),
|
EncryptedUsername("encrypted_username"),
|
||||||
FileBased("file"),
|
FileBased("file"),
|
||||||
DirectoryBased("directory");
|
DirectoryBased("directory");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the username associated to [file], following the convention of the current
|
* Returns the username associated to [file], following the convention of the current
|
||||||
* [DirectoryStructure].
|
* [DirectoryStructure].
|
||||||
*
|
*
|
||||||
* Examples:
|
* Examples:
|
||||||
* - * --> null (EncryptedUsername)
|
* - * --> null (EncryptedUsername)
|
||||||
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
|
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
|
||||||
* - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
|
* - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
|
||||||
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
|
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
|
||||||
*/
|
*/
|
||||||
fun getUsernameFor(file: File): String? = when (this) {
|
fun getUsernameFor(file: File): String? =
|
||||||
EncryptedUsername -> null
|
when (this) {
|
||||||
FileBased -> file.nameWithoutExtension
|
EncryptedUsername -> null
|
||||||
DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
|
FileBased -> file.nameWithoutExtension
|
||||||
|
DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the origin identifier associated to [file], following the convention of the current
|
* Returns the origin identifier associated to [file], following the convention of the current
|
||||||
* [DirectoryStructure].
|
* [DirectoryStructure].
|
||||||
*
|
*
|
||||||
* At least one of [DirectoryStructure.getIdentifierFor] and
|
* At least one of [DirectoryStructure.getIdentifierFor] and
|
||||||
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
|
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
|
||||||
*
|
*
|
||||||
* Examples:
|
* Examples:
|
||||||
* - work/example.org.gpg --> example.org (EncryptedUsername)
|
* - work/example.org.gpg --> example.org (EncryptedUsername)
|
||||||
* - work/example.org/john@doe.org.gpg --> example.org (FileBased)
|
* - work/example.org/john@doe.org.gpg --> example.org (FileBased)
|
||||||
* - example.org.gpg --> example.org (FileBased, fallback)
|
* - example.org.gpg --> example.org (FileBased, fallback)
|
||||||
* - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
|
* - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
|
||||||
* - Temporary PIN.gpg --> null (DirectoryBased)
|
* - Temporary PIN.gpg --> null (DirectoryBased)
|
||||||
*/
|
*/
|
||||||
fun getIdentifierFor(file: File): String? = when (this) {
|
fun getIdentifierFor(file: File): String? =
|
||||||
EncryptedUsername -> file.nameWithoutExtension
|
when (this) {
|
||||||
FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
|
EncryptedUsername -> file.nameWithoutExtension
|
||||||
DirectoryBased -> file.parentFile?.parent
|
FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
|
||||||
|
DirectoryBased -> file.parentFile?.parent
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the path components of [file] until right before the component that contains the
|
* Returns the path components of [file] until right before the component that contains the origin
|
||||||
* origin identifier according to the current [DirectoryStructure].
|
* identifier according to the current [DirectoryStructure].
|
||||||
*
|
*
|
||||||
* Examples:
|
* Examples:
|
||||||
* - work/example.org.gpg --> work (EncryptedUsername)
|
* - work/example.org.gpg --> work (EncryptedUsername)
|
||||||
* - work/example.org/john@doe.org.gpg --> work (FileBased)
|
* - work/example.org/john@doe.org.gpg --> work (FileBased)
|
||||||
* - example.org/john@doe.org.gpg --> null (FileBased)
|
* - example.org/john@doe.org.gpg --> null (FileBased)
|
||||||
* - john@doe.org.gpg --> null (FileBased)
|
* - john@doe.org.gpg --> null (FileBased)
|
||||||
* - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
|
* - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
|
||||||
* - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
|
* - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
|
||||||
*/
|
*/
|
||||||
fun getPathToIdentifierFor(file: File): String? = when (this) {
|
fun getPathToIdentifierFor(file: File): String? =
|
||||||
EncryptedUsername -> file.parent
|
when (this) {
|
||||||
FileBased -> file.parentFile?.parent
|
EncryptedUsername -> file.parent
|
||||||
DirectoryBased -> file.parentFile?.parentFile?.parent
|
FileBased -> file.parentFile?.parent
|
||||||
|
DirectoryBased -> file.parentFile?.parentFile?.parent
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the path component of [file] following the origin identifier according to the current
|
* Returns the path component of [file] following the origin identifier according to the current
|
||||||
* [DirectoryStructure] (without file extension).
|
* [DirectoryStructure](without file extension).
|
||||||
*
|
*
|
||||||
* At least one of [DirectoryStructure.getIdentifierFor] and
|
* At least one of [DirectoryStructure.getIdentifierFor] and
|
||||||
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
|
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
|
||||||
*
|
*
|
||||||
* Examples:
|
* Examples:
|
||||||
* - * --> null (EncryptedUsername)
|
* - * --> null (EncryptedUsername)
|
||||||
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
|
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
|
||||||
* - example.org.gpg --> null (FileBased, fallback)
|
* - example.org.gpg --> null (FileBased, fallback)
|
||||||
* - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
|
* - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
|
||||||
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
|
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
|
||||||
*/
|
*/
|
||||||
fun getAccountPartFor(file: File): String? = when (this) {
|
fun getAccountPartFor(file: File): String? =
|
||||||
EncryptedUsername -> null
|
when (this) {
|
||||||
FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
|
EncryptedUsername -> null
|
||||||
DirectoryBased -> file.parentFile?.let { parentFile ->
|
FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
|
||||||
"${parentFile.name}/${file.nameWithoutExtension}"
|
DirectoryBased -> file.parentFile?.let { parentFile -> "${parentFile.name}/${file.nameWithoutExtension}" }
|
||||||
} ?: file.nameWithoutExtension
|
?: file.nameWithoutExtension
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) {
|
fun getSaveFolderName(sanitizedIdentifier: String, username: String?) =
|
||||||
EncryptedUsername -> "/"
|
when (this) {
|
||||||
FileBased -> sanitizedIdentifier
|
EncryptedUsername -> "/"
|
||||||
DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
|
FileBased -> sanitizedIdentifier
|
||||||
|
DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSaveFileName(username: String?, identifier: String) = when (this) {
|
fun getSaveFileName(username: String?, identifier: String) =
|
||||||
EncryptedUsername -> identifier
|
when (this) {
|
||||||
FileBased -> username
|
EncryptedUsername -> identifier
|
||||||
DirectoryBased -> "password"
|
FileBased -> username
|
||||||
|
DirectoryBased -> "password"
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
val DEFAULT = FileBased
|
val DEFAULT = FileBased
|
||||||
|
|
||||||
private val reverseMap = values().associateBy { it.value }
|
private val reverseMap = values().associateBy { it.value }
|
||||||
fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
|
fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object AutofillPreferences {
|
object AutofillPreferences {
|
||||||
|
|
||||||
fun directoryStructure(context: Context): DirectoryStructure {
|
fun directoryStructure(context: Context): DirectoryStructure {
|
||||||
val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
|
val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
|
||||||
return DirectoryStructure.fromValue(value)
|
return DirectoryStructure.fromValue(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun credentialsFromStoreEntry(
|
fun credentialsFromStoreEntry(
|
||||||
context: Context,
|
context: Context,
|
||||||
file: File,
|
file: File,
|
||||||
entry: PasswordEntry,
|
entry: PasswordEntry,
|
||||||
directoryStructure: DirectoryStructure
|
directoryStructure: DirectoryStructure
|
||||||
): Credentials {
|
): Credentials {
|
||||||
// Always give priority to a username stored in the encrypted extras
|
// Always give priority to a username stored in the encrypted extras
|
||||||
val username = entry.username
|
val username = entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
|
||||||
?: directoryStructure.getUsernameFor(file)
|
return Credentials(username, entry.password, entry.calculateTotpCode())
|
||||||
?: context.getDefaultUsername()
|
}
|
||||||
return Credentials(username, entry.password, entry.calculateTotpCode())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,176 +30,178 @@ import java.io.File
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
class AutofillResponseBuilder(form: FillableForm) {
|
class AutofillResponseBuilder(form: FillableForm) {
|
||||||
|
|
||||||
private val formOrigin = form.formOrigin
|
private val formOrigin = form.formOrigin
|
||||||
private val scenario = form.scenario
|
private val scenario = form.scenario
|
||||||
private val ignoredIds = form.ignoredIds
|
private val ignoredIds = form.ignoredIds
|
||||||
private val saveFlags = form.saveFlags
|
private val saveFlags = form.saveFlags
|
||||||
private val clientState = form.toClientState()
|
private val clientState = form.toClientState()
|
||||||
|
|
||||||
// We do not offer save when the only relevant field is a username field or there is no field.
|
// We do not offer save when the only relevant field is a username field or there is no field.
|
||||||
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
|
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
|
||||||
private val canBeSaved = saveFlags != null && scenarioSupportsSave
|
private val canBeSaved = saveFlags != null && scenarioSupportsSave
|
||||||
|
|
||||||
private fun makeIntentDataset(
|
private fun makeIntentDataset(
|
||||||
context: Context,
|
context: Context,
|
||||||
action: AutofillAction,
|
action: AutofillAction,
|
||||||
intentSender: IntentSender,
|
intentSender: IntentSender,
|
||||||
metadata: DatasetMetadata,
|
metadata: DatasetMetadata,
|
||||||
): Dataset {
|
): Dataset {
|
||||||
return Dataset.Builder(makeRemoteView(context, metadata)).run {
|
return Dataset.Builder(makeRemoteView(context, metadata)).run {
|
||||||
fillWith(scenario, action, credentials = null)
|
fillWith(scenario, action, credentials = null)
|
||||||
setAuthentication(intentSender)
|
setAuthentication(intentSender)
|
||||||
build()
|
build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeMatchDataset(context: Context, file: File): Dataset? {
|
||||||
|
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
||||||
|
val metadata = makeFillMatchMetadata(context, file)
|
||||||
|
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
||||||
|
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeSearchDataset(context: Context): Dataset? {
|
||||||
|
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
|
||||||
|
val metadata = makeSearchAndFillMetadata(context)
|
||||||
|
val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
|
||||||
|
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeGenerateDataset(context: Context): Dataset? {
|
||||||
|
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
|
||||||
|
val metadata = makeGenerateAndFillMetadata(context)
|
||||||
|
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
|
||||||
|
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
|
||||||
|
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
|
||||||
|
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
|
||||||
|
val metadata = makeFillOtpFromSmsMetadata(context)
|
||||||
|
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
|
||||||
|
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makePublisherChangedDataset(
|
||||||
|
context: Context,
|
||||||
|
publisherChangedException: AutofillPublisherChangedException,
|
||||||
|
): Dataset {
|
||||||
|
val metadata = makeWarningMetadata(context)
|
||||||
|
// If the user decides to trust the new publisher, they can choose reset the list of
|
||||||
|
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
|
||||||
|
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
|
||||||
|
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
|
||||||
|
val fillResponseAfterReset = makeFillResponse(context, emptyList())
|
||||||
|
val intentSender =
|
||||||
|
AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
|
||||||
|
context,
|
||||||
|
publisherChangedException,
|
||||||
|
fillResponseAfterReset
|
||||||
|
)
|
||||||
|
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makePublisherChangedResponse(
|
||||||
|
context: Context,
|
||||||
|
publisherChangedException: AutofillPublisherChangedException
|
||||||
|
): FillResponse {
|
||||||
|
return FillResponse.Builder().run {
|
||||||
|
addDataset(makePublisherChangedDataset(context, publisherChangedException))
|
||||||
|
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||||
|
build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
|
||||||
|
// See:
|
||||||
|
// https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
|
||||||
|
private fun makeSaveInfo(): SaveInfo? {
|
||||||
|
if (!canBeSaved) return null
|
||||||
|
check(saveFlags != null)
|
||||||
|
val idsToSave = scenario.fieldsToSave.toTypedArray()
|
||||||
|
if (idsToSave.isEmpty()) return null
|
||||||
|
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
|
||||||
|
if (scenario.hasUsername) {
|
||||||
|
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
|
||||||
|
}
|
||||||
|
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
|
||||||
|
setFlags(saveFlags)
|
||||||
|
build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
|
||||||
|
var datasetCount = 0
|
||||||
|
return FillResponse.Builder().run {
|
||||||
|
for (file in matchedFiles) {
|
||||||
|
makeMatchDataset(context, file)?.let {
|
||||||
|
datasetCount++
|
||||||
|
addDataset(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
makeGenerateDataset(context)?.let {
|
||||||
private fun makeMatchDataset(context: Context, file: File): Dataset? {
|
datasetCount++
|
||||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
addDataset(it)
|
||||||
val metadata = makeFillMatchMetadata(context, file)
|
}
|
||||||
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
makeFillOtpFromSmsDataset(context)?.let {
|
||||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
|
datasetCount++
|
||||||
}
|
addDataset(it)
|
||||||
|
}
|
||||||
|
makeSearchDataset(context)?.let {
|
||||||
private fun makeSearchDataset(context: Context): Dataset? {
|
datasetCount++
|
||||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
|
addDataset(it)
|
||||||
val metadata = makeSearchAndFillMetadata(context)
|
}
|
||||||
val intentSender =
|
if (datasetCount == 0) return null
|
||||||
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
|
setHeader(
|
||||||
}
|
makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))
|
||||||
|
|
||||||
private fun makeGenerateDataset(context: Context): Dataset? {
|
|
||||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
|
|
||||||
val metadata = makeGenerateAndFillMetadata(context)
|
|
||||||
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
|
|
||||||
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
|
|
||||||
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
|
|
||||||
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
|
|
||||||
val metadata = makeFillOtpFromSmsMetadata(context)
|
|
||||||
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
|
|
||||||
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makePublisherChangedDataset(
|
|
||||||
context: Context,
|
|
||||||
publisherChangedException: AutofillPublisherChangedException,
|
|
||||||
): Dataset {
|
|
||||||
val metadata = makeWarningMetadata(context)
|
|
||||||
// If the user decides to trust the new publisher, they can choose reset the list of
|
|
||||||
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
|
|
||||||
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
|
|
||||||
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
|
|
||||||
val fillResponseAfterReset = makeFillResponse(context, emptyList())
|
|
||||||
val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
|
|
||||||
context, publisherChangedException, fillResponseAfterReset
|
|
||||||
)
|
)
|
||||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
|
}
|
||||||
|
makeSaveInfo()?.let { setSaveInfo(it) }
|
||||||
|
setClientState(clientState)
|
||||||
|
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||||
|
build()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun makePublisherChangedResponse(
|
/** Creates and returns a suitable [FillResponse] to the Autofill framework. */
|
||||||
context: Context,
|
fun fillCredentials(context: Context, callback: FillCallback) {
|
||||||
publisherChangedException: AutofillPublisherChangedException
|
AutofillMatcher.getMatchesFor(context, formOrigin)
|
||||||
): FillResponse {
|
.fold(
|
||||||
return FillResponse.Builder().run {
|
success = { matchedFiles -> callback.onSuccess(makeFillResponse(context, matchedFiles)) },
|
||||||
addDataset(makePublisherChangedDataset(context, publisherChangedException))
|
failure = { e ->
|
||||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
e(e)
|
||||||
build()
|
callback.onSuccess(makePublisherChangedResponse(context, e))
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
|
companion object {
|
||||||
// See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
|
|
||||||
private fun makeSaveInfo(): SaveInfo? {
|
fun makeFillInDataset(
|
||||||
if (!canBeSaved) return null
|
context: Context,
|
||||||
check(saveFlags != null)
|
credentials: Credentials,
|
||||||
val idsToSave = scenario.fieldsToSave.toTypedArray()
|
clientState: Bundle,
|
||||||
if (idsToSave.isEmpty()) return null
|
action: AutofillAction
|
||||||
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
|
): Dataset {
|
||||||
if (scenario.hasUsername) {
|
val scenario = AutofillScenario.fromClientState(clientState)
|
||||||
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
|
// Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
|
||||||
}
|
// though they are rarely shown.
|
||||||
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
|
// FIXME: We should clone the original dataset here and add the credentials to be filled
|
||||||
setFlags(saveFlags)
|
// in. Otherwise, the entry in the cached list of datasets will be overwritten by the
|
||||||
build()
|
// fill-in dataset without any visual representation. This causes it to be missing from
|
||||||
}
|
// the Autofill suggestions shown after the user clears the filled out form fields.
|
||||||
}
|
val builder =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
|
Dataset.Builder()
|
||||||
var datasetCount = 0
|
} else {
|
||||||
return FillResponse.Builder().run {
|
Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))
|
||||||
for (file in matchedFiles) {
|
|
||||||
makeMatchDataset(context, file)?.let {
|
|
||||||
datasetCount++
|
|
||||||
addDataset(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
makeGenerateDataset(context)?.let {
|
|
||||||
datasetCount++
|
|
||||||
addDataset(it)
|
|
||||||
}
|
|
||||||
makeFillOtpFromSmsDataset(context)?.let {
|
|
||||||
datasetCount++
|
|
||||||
addDataset(it)
|
|
||||||
}
|
|
||||||
makeSearchDataset(context)?.let {
|
|
||||||
datasetCount++
|
|
||||||
addDataset(it)
|
|
||||||
}
|
|
||||||
if (datasetCount == 0) return null
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
||||||
setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
|
|
||||||
}
|
|
||||||
makeSaveInfo()?.let { setSaveInfo(it) }
|
|
||||||
setClientState(clientState)
|
|
||||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
|
||||||
build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates and returns a suitable [FillResponse] to the Autofill framework.
|
|
||||||
*/
|
|
||||||
fun fillCredentials(context: Context, callback: FillCallback) {
|
|
||||||
AutofillMatcher.getMatchesFor(context, formOrigin).fold(
|
|
||||||
success = { matchedFiles ->
|
|
||||||
callback.onSuccess(makeFillResponse(context, matchedFiles))
|
|
||||||
},
|
|
||||||
failure = { e ->
|
|
||||||
e(e)
|
|
||||||
callback.onSuccess(makePublisherChangedResponse(context, e))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun makeFillInDataset(
|
|
||||||
context: Context,
|
|
||||||
credentials: Credentials,
|
|
||||||
clientState: Bundle,
|
|
||||||
action: AutofillAction
|
|
||||||
): Dataset {
|
|
||||||
val scenario = AutofillScenario.fromClientState(clientState)
|
|
||||||
// Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
|
|
||||||
// though they are rarely shown.
|
|
||||||
// FIXME: We should clone the original dataset here and add the credentials to be filled
|
|
||||||
// in. Otherwise, the entry in the cached list of datasets will be overwritten by the
|
|
||||||
// fill-in dataset without any visual representation. This causes it to be missing from
|
|
||||||
// the Autofill suggestions shown after the user clears the filled out form fields.
|
|
||||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
||||||
Dataset.Builder()
|
|
||||||
} else {
|
|
||||||
Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))
|
|
||||||
}
|
|
||||||
return builder.run {
|
|
||||||
if (scenario != null) fillWith(scenario, action, credentials)
|
|
||||||
else e { "Failed to recover scenario from client state" }
|
|
||||||
build()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return builder.run {
|
||||||
|
if (scenario != null) fillWith(scenario, action, credentials)
|
||||||
|
else e { "Failed to recover scenario from client state" }
|
||||||
|
build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,88 +26,74 @@ import java.io.File
|
||||||
data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int)
|
data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int)
|
||||||
|
|
||||||
fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews {
|
fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews {
|
||||||
return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
|
return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
|
||||||
setTextViewText(R.id.title, metadata.title)
|
setTextViewText(R.id.title, metadata.title)
|
||||||
if (metadata.subtitle != null) {
|
if (metadata.subtitle != null) {
|
||||||
setTextViewText(R.id.summary, metadata.subtitle)
|
setTextViewText(R.id.summary, metadata.subtitle)
|
||||||
} else {
|
} else {
|
||||||
setViewVisibility(R.id.summary, View.GONE)
|
setViewVisibility(R.id.summary, View.GONE)
|
||||||
}
|
|
||||||
if (metadata.iconRes != Resources.ID_NULL) {
|
|
||||||
setImageViewResource(R.id.icon, metadata.iconRes)
|
|
||||||
} else {
|
|
||||||
setViewVisibility(R.id.icon, View.GONE)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (metadata.iconRes != Resources.ID_NULL) {
|
||||||
|
setImageViewResource(R.id.icon, metadata.iconRes)
|
||||||
|
} else {
|
||||||
|
setViewVisibility(R.id.icon, View.GONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
@SuppressLint("RestrictedApi")
|
||||||
fun makeInlinePresentation(context: Context, imeSpec: InlinePresentationSpec, metadata: DatasetMetadata): InlinePresentation? {
|
fun makeInlinePresentation(
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
|
context: Context,
|
||||||
return null
|
imeSpec: InlinePresentationSpec,
|
||||||
|
metadata: DatasetMetadata
|
||||||
|
): InlinePresentation? {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
|
||||||
|
|
||||||
if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style))
|
if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null
|
||||||
return null
|
|
||||||
|
|
||||||
val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0)
|
val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0)
|
||||||
val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run {
|
val slice =
|
||||||
setTitle(metadata.title)
|
InlineSuggestionUi.newContentBuilder(launchIntent).run {
|
||||||
if (metadata.subtitle != null)
|
setTitle(metadata.title)
|
||||||
setSubtitle(metadata.subtitle)
|
if (metadata.subtitle != null) setSubtitle(metadata.subtitle)
|
||||||
setContentDescription(if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title)
|
setContentDescription(
|
||||||
setStartIcon(Icon.createWithResource(context, metadata.iconRes))
|
if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title
|
||||||
build().slice
|
)
|
||||||
|
setStartIcon(Icon.createWithResource(context, metadata.iconRes))
|
||||||
|
build().slice
|
||||||
}
|
}
|
||||||
|
|
||||||
return InlinePresentation(slice, imeSpec, false)
|
return InlinePresentation(slice, imeSpec, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata {
|
fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata {
|
||||||
val directoryStructure = AutofillPreferences.directoryStructure(context)
|
val directoryStructure = AutofillPreferences.directoryStructure(context)
|
||||||
val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
|
val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
|
||||||
val title = directoryStructure.getIdentifierFor(relativeFile)
|
val title = directoryStructure.getIdentifierFor(relativeFile) ?: directoryStructure.getAccountPartFor(relativeFile)!!
|
||||||
?: directoryStructure.getAccountPartFor(relativeFile)!!
|
val subtitle = directoryStructure.getAccountPartFor(relativeFile)
|
||||||
val subtitle = directoryStructure.getAccountPartFor(relativeFile)
|
return DatasetMetadata(title, subtitle, R.drawable.ic_person_black_24dp)
|
||||||
return DatasetMetadata(
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
R.drawable.ic_person_black_24dp
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun makeSearchAndFillMetadata(context: Context) = DatasetMetadata(
|
fun makeSearchAndFillMetadata(context: Context) =
|
||||||
context.getString(R.string.oreo_autofill_search_in_store),
|
DatasetMetadata(context.getString(R.string.oreo_autofill_search_in_store), null, R.drawable.ic_search_black_24dp)
|
||||||
null,
|
|
||||||
R.drawable.ic_search_black_24dp
|
|
||||||
)
|
|
||||||
|
|
||||||
fun makeGenerateAndFillMetadata(context: Context) = DatasetMetadata(
|
fun makeGenerateAndFillMetadata(context: Context) =
|
||||||
|
DatasetMetadata(
|
||||||
context.getString(R.string.oreo_autofill_generate_password),
|
context.getString(R.string.oreo_autofill_generate_password),
|
||||||
null,
|
null,
|
||||||
R.drawable.ic_autofill_new_password
|
R.drawable.ic_autofill_new_password
|
||||||
)
|
)
|
||||||
|
|
||||||
fun makeFillOtpFromSmsMetadata(context: Context) = DatasetMetadata(
|
fun makeFillOtpFromSmsMetadata(context: Context) =
|
||||||
context.getString(R.string.oreo_autofill_fill_otp_from_sms),
|
DatasetMetadata(context.getString(R.string.oreo_autofill_fill_otp_from_sms), null, R.drawable.ic_autofill_sms)
|
||||||
null,
|
|
||||||
R.drawable.ic_autofill_sms
|
|
||||||
)
|
|
||||||
|
|
||||||
fun makeEmptyMetadata() = DatasetMetadata(
|
fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
|
||||||
"PLACEHOLDER",
|
|
||||||
"PLACEHOLDER",
|
|
||||||
R.mipmap.ic_launcher
|
|
||||||
)
|
|
||||||
|
|
||||||
fun makeWarningMetadata(context: Context) = DatasetMetadata(
|
fun makeWarningMetadata(context: Context) =
|
||||||
|
DatasetMetadata(
|
||||||
context.getString(R.string.oreo_autofill_warning_publisher_dataset_title),
|
context.getString(R.string.oreo_autofill_warning_publisher_dataset_title),
|
||||||
context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary),
|
context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary),
|
||||||
R.drawable.ic_warning_red_24dp
|
R.drawable.ic_warning_red_24dp
|
||||||
)
|
)
|
||||||
|
|
||||||
fun makeHeaderMetadata(title: String) = DatasetMetadata(
|
fun makeHeaderMetadata(title: String) = DatasetMetadata(title, null, 0)
|
||||||
title,
|
|
||||||
null,
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
|
@ -8,36 +8,33 @@ package dev.msfjarvis.aps.util.crypto
|
||||||
import me.msfjarvis.openpgpktx.util.OpenPgpUtils
|
import me.msfjarvis.openpgpktx.util.OpenPgpUtils
|
||||||
|
|
||||||
sealed class GpgIdentifier {
|
sealed class GpgIdentifier {
|
||||||
data class KeyId(val id: Long) : GpgIdentifier()
|
data class KeyId(val id: Long) : GpgIdentifier()
|
||||||
data class UserId(val email: String) : GpgIdentifier()
|
data class UserId(val email: String) : GpgIdentifier()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@OptIn(ExperimentalUnsignedTypes::class)
|
@OptIn(ExperimentalUnsignedTypes::class)
|
||||||
fun fromString(identifier: String): GpgIdentifier? {
|
fun fromString(identifier: String): GpgIdentifier? {
|
||||||
if (identifier.isEmpty()) return null
|
if (identifier.isEmpty()) return null
|
||||||
// Match long key IDs:
|
// Match long key IDs:
|
||||||
// FF22334455667788 or 0xFF22334455667788
|
// FF22334455667788 or 0xFF22334455667788
|
||||||
val maybeLongKeyId = identifier.removePrefix("0x").takeIf {
|
val maybeLongKeyId = identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{16}".toRegex()) }
|
||||||
it.matches("[a-fA-F0-9]{16}".toRegex())
|
if (maybeLongKeyId != null) {
|
||||||
}
|
val keyId = maybeLongKeyId.toULong(16)
|
||||||
if (maybeLongKeyId != null) {
|
return KeyId(keyId.toLong())
|
||||||
val keyId = maybeLongKeyId.toULong(16)
|
}
|
||||||
return KeyId(keyId.toLong())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match fingerprints:
|
// Match fingerprints:
|
||||||
// FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
|
// FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
|
||||||
val maybeFingerprint = identifier.removePrefix("0x").takeIf {
|
val maybeFingerprint = identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{40}".toRegex()) }
|
||||||
it.matches("[a-fA-F0-9]{40}".toRegex())
|
if (maybeFingerprint != null) {
|
||||||
}
|
// Truncating to the long key ID is not a security issue since OpenKeychain only
|
||||||
if (maybeFingerprint != null) {
|
// accepts
|
||||||
// Truncating to the long key ID is not a security issue since OpenKeychain only accepts
|
// non-ambiguous key IDs.
|
||||||
// non-ambiguous key IDs.
|
val keyId = maybeFingerprint.takeLast(16).toULong(16)
|
||||||
val keyId = maybeFingerprint.takeLast(16).toULong(16)
|
return KeyId(keyId.toLong())
|
||||||
return KeyId(keyId.toLong())
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) }
|
return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,146 +34,115 @@ import dev.msfjarvis.aps.data.repo.PasswordRepository
|
||||||
import dev.msfjarvis.aps.util.git.operation.GitOperation
|
import dev.msfjarvis.aps.util.git.operation.GitOperation
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension function for [AlertDialog] that requests focus for the
|
* Extension function for [AlertDialog] that requests focus for the view whose id is [id]. Solution
|
||||||
* view whose id is [id]. Solution based on a StackOverflow
|
* based on a StackOverflow answer: https://stackoverflow.com/a/13056259/297261
|
||||||
* answer: https://stackoverflow.com/a/13056259/297261
|
|
||||||
*/
|
*/
|
||||||
fun <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) {
|
fun <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) {
|
||||||
setOnShowListener {
|
setOnShowListener {
|
||||||
findViewById<T>(id)?.apply {
|
findViewById<T>(id)?.apply {
|
||||||
setOnFocusChangeListener { v, _ ->
|
setOnFocusChangeListener { v, _ ->
|
||||||
v.post {
|
v.post { context.getSystemService<InputMethodManager>()?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) }
|
||||||
context.getSystemService<InputMethodManager>()
|
}
|
||||||
?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT)
|
requestFocus()
|
||||||
}
|
|
||||||
}
|
|
||||||
requestFocus()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */
|
||||||
* Get an instance of [AutofillManager]. Only
|
|
||||||
* available on Android Oreo and above
|
|
||||||
*/
|
|
||||||
val Context.autofillManager: AutofillManager?
|
val Context.autofillManager: AutofillManager?
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O) get() = getSystemService()
|
||||||
get() = getSystemService()
|
|
||||||
|
|
||||||
/**
|
/** Get an instance of [ClipboardManager] */
|
||||||
* Get an instance of [ClipboardManager]
|
|
||||||
*/
|
|
||||||
val Context.clipboard
|
val Context.clipboard
|
||||||
get() = getSystemService<ClipboardManager>()
|
get() = getSystemService<ClipboardManager>()
|
||||||
|
|
||||||
/**
|
/** Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at each call site */
|
||||||
* Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at
|
|
||||||
* each call site
|
|
||||||
*/
|
|
||||||
fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation")
|
fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation")
|
||||||
|
|
||||||
/**
|
/** Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP proxy. */
|
||||||
* Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP
|
|
||||||
* proxy.
|
|
||||||
*/
|
|
||||||
fun Context.getEncryptedProxyPrefs() = getEncryptedPrefs("http_proxy")
|
fun Context.getEncryptedProxyPrefs() = getEncryptedPrefs("http_proxy")
|
||||||
|
|
||||||
/**
|
/** Get an instance of [EncryptedSharedPreferences] with the given [fileName] */
|
||||||
* Get an instance of [EncryptedSharedPreferences] with the given [fileName]
|
|
||||||
*/
|
|
||||||
private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences {
|
private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences {
|
||||||
val masterKeyAlias = MasterKey.Builder(applicationContext)
|
val masterKeyAlias = MasterKey.Builder(applicationContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
return EncryptedSharedPreferences.create(
|
||||||
.build()
|
applicationContext,
|
||||||
return EncryptedSharedPreferences.create(
|
fileName,
|
||||||
applicationContext,
|
masterKeyAlias,
|
||||||
fileName,
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
masterKeyAlias,
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
)
|
||||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Get an instance of [KeyguardManager] */
|
||||||
* Get an instance of [KeyguardManager]
|
|
||||||
*/
|
|
||||||
val Context.keyguardManager: KeyguardManager
|
val Context.keyguardManager: KeyguardManager
|
||||||
get() = getSystemService()!!
|
get() = getSystemService()!!
|
||||||
|
|
||||||
/**
|
/** Get the default [SharedPreferences] instance */
|
||||||
* Get the default [SharedPreferences] instance
|
|
||||||
*/
|
|
||||||
val Context.sharedPrefs: SharedPreferences
|
val Context.sharedPrefs: SharedPreferences
|
||||||
get() = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
get() = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||||
|
|
||||||
|
/** Resolve [attr] from the [Context]'s theme */
|
||||||
/**
|
|
||||||
* Resolve [attr] from the [Context]'s theme
|
|
||||||
*/
|
|
||||||
fun Context.resolveAttribute(attr: Int): Int {
|
fun Context.resolveAttribute(attr: Int): Int {
|
||||||
val typedValue = TypedValue()
|
val typedValue = TypedValue()
|
||||||
this.theme.resolveAttribute(attr, typedValue, true)
|
this.theme.resolveAttribute(attr, typedValue, true)
|
||||||
return typedValue.data
|
return typedValue.data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Commit changes to the store from a [FragmentActivity] using
|
* Commit changes to the store from a [FragmentActivity] using a custom implementation of
|
||||||
* a custom implementation of [GitOperation]
|
* [GitOperation]
|
||||||
*/
|
*/
|
||||||
suspend fun FragmentActivity.commitChange(
|
suspend fun FragmentActivity.commitChange(
|
||||||
message: String,
|
message: String,
|
||||||
): Result<Unit, Throwable> {
|
): Result<Unit, Throwable> {
|
||||||
if (!PasswordRepository.isGitRepo()) {
|
if (!PasswordRepository.isGitRepo()) {
|
||||||
return Ok(Unit)
|
return Ok(Unit)
|
||||||
}
|
}
|
||||||
return object : GitOperation(this@commitChange) {
|
return object : GitOperation(this@commitChange) {
|
||||||
override val commands = arrayOf(
|
override val commands =
|
||||||
// Stage all files
|
arrayOf(
|
||||||
git.add().addFilepattern("."),
|
// Stage all files
|
||||||
// Populate the changed files count
|
git.add().addFilepattern("."),
|
||||||
git.status(),
|
// Populate the changed files count
|
||||||
// Commit everything! If anything changed, that is.
|
git.status(),
|
||||||
git.commit().setAll(true).setMessage(message),
|
// Commit everything! If anything changed, that is.
|
||||||
|
git.commit().setAll(true).setMessage(message),
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun preExecute(): Boolean {
|
override fun preExecute(): Boolean {
|
||||||
d { "Committing with message: '$message'" }
|
d { "Committing with message: '$message'" }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}.execute()
|
}
|
||||||
|
.execute()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Check if [permission] has been granted to the app. */
|
||||||
* Check if [permission] has been granted to the app.
|
|
||||||
*/
|
|
||||||
fun FragmentActivity.isPermissionGranted(permission: String): Boolean {
|
fun FragmentActivity.isPermissionGranted(permission: String): Boolean {
|
||||||
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a [Snackbar] in a [FragmentActivity] and correctly
|
* Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a
|
||||||
* anchor it to a [com.google.android.material.floatingactionbutton.FloatingActionButton]
|
* [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the
|
||||||
* if one exists in the [view]
|
* [view]
|
||||||
*/
|
*/
|
||||||
fun FragmentActivity.snackbar(
|
fun FragmentActivity.snackbar(
|
||||||
view: View = findViewById(android.R.id.content),
|
view: View = findViewById(android.R.id.content),
|
||||||
message: String,
|
message: String,
|
||||||
length: Int = Snackbar.LENGTH_SHORT,
|
length: Int = Snackbar.LENGTH_SHORT,
|
||||||
): Snackbar {
|
): Snackbar {
|
||||||
val snackbar = Snackbar.make(view, message, length)
|
val snackbar = Snackbar.make(view, message, length)
|
||||||
snackbar.anchorView = findViewById(R.id.fab)
|
snackbar.anchorView = findViewById(R.id.fab)
|
||||||
snackbar.show()
|
snackbar.show()
|
||||||
return snackbar
|
return snackbar
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */
|
||||||
* Simplifies the common `getString(key, null) ?: defaultValue` case slightly
|
|
||||||
*/
|
|
||||||
fun SharedPreferences.getString(key: String): String? = getString(key, null)
|
fun SharedPreferences.getString(key: String): String? = getString(key, null)
|
||||||
|
|
||||||
/**
|
/** Convert this [String] to its [Base64] representation */
|
||||||
* Convert this [String] to its [Base64] representation
|
|
||||||
*/
|
|
||||||
fun String.base64(): String {
|
fun String.base64(): String {
|
||||||
return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP)
|
return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP)
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,53 +12,40 @@ import java.util.Date
|
||||||
import org.eclipse.jgit.lib.ObjectId
|
import org.eclipse.jgit.lib.ObjectId
|
||||||
import org.eclipse.jgit.revwalk.RevCommit
|
import org.eclipse.jgit.revwalk.RevCommit
|
||||||
|
|
||||||
/**
|
/** The default OpenPGP provider for the app */
|
||||||
* The default OpenPGP provider for the app
|
|
||||||
*/
|
|
||||||
const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
|
const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
|
||||||
|
|
||||||
/**
|
/** Clears the given [flag] from the value of this [Int] */
|
||||||
* Clears the given [flag] from the value of this [Int]
|
|
||||||
*/
|
|
||||||
fun Int.clearFlag(flag: Int): Int {
|
fun Int.clearFlag(flag: Int): Int {
|
||||||
return this and flag.inv()
|
return this and flag.inv()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Checks if this [Int] contains the given [flag] */
|
||||||
* Checks if this [Int] contains the given [flag]
|
|
||||||
*/
|
|
||||||
infix fun Int.hasFlag(flag: Int): Boolean {
|
infix fun Int.hasFlag(flag: Int): Boolean {
|
||||||
return this and flag == flag
|
return this and flag == flag
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Checks whether this [File] is a directory that contains [other]. */
|
||||||
* Checks whether this [File] is a directory that contains [other].
|
|
||||||
*/
|
|
||||||
fun File.contains(other: File): Boolean {
|
fun File.contains(other: File): Boolean {
|
||||||
if (!isDirectory)
|
if (!isDirectory) return false
|
||||||
return false
|
if (!other.exists()) return false
|
||||||
if (!other.exists())
|
val relativePath =
|
||||||
return false
|
runCatching { other.relativeTo(this) }.getOrElse {
|
||||||
val relativePath = runCatching {
|
return false
|
||||||
other.relativeTo(this)
|
|
||||||
}.getOrElse {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
// Direct containment is equivalent to the relative path being equal to the filename.
|
// Direct containment is equivalent to the relative path being equal to the filename.
|
||||||
return relativePath.path == other.name
|
return relativePath.path == other.name
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if this [File] is in the password repository directory as given
|
* Checks if this [File] is in the password repository directory as given by
|
||||||
* by [PasswordRepository.getRepositoryDirectory]
|
* [PasswordRepository.getRepositoryDirectory]
|
||||||
*/
|
*/
|
||||||
fun File.isInsideRepository(): Boolean {
|
fun File.isInsideRepository(): Boolean {
|
||||||
return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath)
|
return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Recursively lists the files in this [File], skipping any directories it encounters. */
|
||||||
* Recursively lists the files in this [File], skipping any directories it encounters.
|
|
||||||
*/
|
|
||||||
fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList()
|
fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -67,7 +54,7 @@ fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toLis
|
||||||
* @see RevCommit.getId
|
* @see RevCommit.getId
|
||||||
*/
|
*/
|
||||||
val RevCommit.hash: String
|
val RevCommit.hash: String
|
||||||
get() = ObjectId.toString(id)
|
get() = ObjectId.toString(id)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Time this commit was made with second precision.
|
* Time this commit was made with second precision.
|
||||||
|
@ -75,16 +62,16 @@ val RevCommit.hash: String
|
||||||
* @see RevCommit.commitTime
|
* @see RevCommit.commitTime
|
||||||
*/
|
*/
|
||||||
val RevCommit.time: Date
|
val RevCommit.time: Date
|
||||||
get() {
|
get() {
|
||||||
val epochSeconds = commitTime.toLong()
|
val epochSeconds = commitTime.toLong()
|
||||||
val epochMilliseconds = epochSeconds * 1000
|
val epochMilliseconds = epochSeconds * 1000
|
||||||
return Date(epochMilliseconds)
|
return Date(epochMilliseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Splits this [String] into an [Array] of [String]s, split on the UNIX LF line ending
|
* Splits this [String] into an [Array] of [String] s, split on the UNIX LF line ending and stripped
|
||||||
* and stripped of any empty lines.
|
* of any empty lines.
|
||||||
*/
|
*/
|
||||||
fun String.splitLines(): Array<String> {
|
fun String.splitLines(): Array<String> {
|
||||||
return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,31 +11,31 @@ import androidx.fragment.app.FragmentManager
|
||||||
import androidx.fragment.app.commit
|
import androidx.fragment.app.commit
|
||||||
import dev.msfjarvis.aps.R
|
import dev.msfjarvis.aps.R
|
||||||
|
|
||||||
/**
|
/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */
|
||||||
* Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally.
|
|
||||||
*/
|
|
||||||
fun Fragment.isPermissionGranted(permission: String): Boolean {
|
fun Fragment.isPermissionGranted(permission: String): Boolean {
|
||||||
return requireActivity().isPermissionGranted(permission)
|
return requireActivity().isPermissionGranted(permission)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] */
|
||||||
* Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity]
|
|
||||||
*/
|
|
||||||
fun Fragment.finish() = requireActivity().finish()
|
fun Fragment.finish() = requireActivity().finish()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a [commit] on this [FragmentManager] with custom animations and adding the [destinationFragment]
|
* Perform a [commit] on this [FragmentManager] with custom animations and adding the
|
||||||
* to the fragment backstack
|
* [destinationFragment] to the fragment backstack
|
||||||
*/
|
*/
|
||||||
fun FragmentManager.performTransactionWithBackStack(destinationFragment: Fragment, @IdRes containerViewId: Int = android.R.id.content) {
|
fun FragmentManager.performTransactionWithBackStack(
|
||||||
commit {
|
destinationFragment: Fragment,
|
||||||
beginTransaction()
|
@IdRes containerViewId: Int = android.R.id.content
|
||||||
addToBackStack(destinationFragment.tag)
|
) {
|
||||||
setCustomAnimations(
|
commit {
|
||||||
R.animator.slide_in_left,
|
beginTransaction()
|
||||||
R.animator.slide_out_left,
|
addToBackStack(destinationFragment.tag)
|
||||||
R.animator.slide_in_right,
|
setCustomAnimations(
|
||||||
R.animator.slide_out_right)
|
R.animator.slide_in_left,
|
||||||
replace(containerViewId, destinationFragment)
|
R.animator.slide_out_left,
|
||||||
}
|
R.animator.slide_in_right,
|
||||||
|
R.animator.slide_out_right
|
||||||
|
)
|
||||||
|
replace(containerViewId, destinationFragment)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
|
|
||||||
package dev.msfjarvis.aps.util.extensions
|
package dev.msfjarvis.aps.util.extensions
|
||||||
|
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
@ -18,48 +17,49 @@ import kotlin.properties.ReadOnlyProperty
|
||||||
import kotlin.reflect.KProperty
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Imported from https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
|
* Imported from
|
||||||
|
* https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
|
||||||
*/
|
*/
|
||||||
class FragmentViewBindingDelegate<T : ViewBinding>(
|
class FragmentViewBindingDelegate<T : ViewBinding>(val fragment: Fragment, val viewBindingFactory: (View) -> T) :
|
||||||
val fragment: Fragment,
|
ReadOnlyProperty<Fragment, T> {
|
||||||
val viewBindingFactory: (View) -> T
|
|
||||||
) : ReadOnlyProperty<Fragment, T> {
|
|
||||||
|
|
||||||
private var binding: T? = null
|
private var binding: T? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
fragment.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
fragment.lifecycle.addObserver(
|
||||||
override fun onCreate(owner: LifecycleOwner) {
|
object : DefaultLifecycleObserver {
|
||||||
fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
|
override fun onCreate(owner: LifecycleOwner) {
|
||||||
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
|
||||||
override fun onDestroy(owner: LifecycleOwner) {
|
viewLifecycleOwner.lifecycle.addObserver(
|
||||||
binding = null
|
object : DefaultLifecycleObserver {
|
||||||
}
|
override fun onDestroy(owner: LifecycleOwner) {
|
||||||
})
|
binding = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
|
||||||
|
val binding = binding
|
||||||
|
if (binding != null) {
|
||||||
|
return binding
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
|
val lifecycle = fragment.viewLifecycleOwner.lifecycle
|
||||||
val binding = binding
|
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
|
||||||
if (binding != null) {
|
throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
|
||||||
return binding
|
|
||||||
}
|
|
||||||
|
|
||||||
val lifecycle = fragment.viewLifecycleOwner.lifecycle
|
|
||||||
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
|
|
||||||
throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
|
fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
|
||||||
FragmentViewBindingDelegate(this, viewBindingFactory)
|
FragmentViewBindingDelegate(this, viewBindingFactory)
|
||||||
|
|
||||||
inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) =
|
inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) =
|
||||||
lazy(LazyThreadSafetyMode.NONE) {
|
lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) }
|
||||||
bindingInflater.invoke(layoutInflater)
|
|
||||||
}
|
|
||||||
|
|
|
@ -12,57 +12,54 @@ import dev.msfjarvis.aps.R
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute].
|
* Supertype for all Git-related [Exception] s that can be thrown by [GitCommandExecutor.execute].
|
||||||
*/
|
*/
|
||||||
sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) {
|
sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) {
|
||||||
|
|
||||||
override val message = super.message!!
|
override val message = super.message!!
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt)
|
private fun buildMessage(@StringRes res: Int, vararg fmt: String) =
|
||||||
}
|
Application.instance.resources.getString(res, *fmt)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. */
|
||||||
* Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand].
|
sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
|
||||||
*/
|
|
||||||
sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
|
|
||||||
|
|
||||||
object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error)
|
object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error)
|
||||||
object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error)
|
object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. */
|
||||||
* Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand].
|
sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
|
||||||
*/
|
|
||||||
sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
|
|
||||||
|
|
||||||
object NonFastForward : PushException(R.string.git_push_nff_error)
|
object NonFastForward : PushException(R.string.git_push_nff_error)
|
||||||
object RemoteRejected : PushException(R.string.git_push_other_error)
|
object RemoteRejected : PushException(R.string.git_push_other_error)
|
||||||
class Generic(message: String) : PushException(R.string.git_push_generic_error, message)
|
class Generic(message: String) : PushException(R.string.git_push_generic_error, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object ErrorMessages {
|
object ErrorMessages {
|
||||||
|
|
||||||
operator fun get(throwable: Throwable?): String {
|
operator fun get(throwable: Throwable?): String {
|
||||||
val resources = Application.instance.resources
|
val resources = Application.instance.resources
|
||||||
if (throwable == null) return resources.getString(R.string.git_unknown_error)
|
if (throwable == null) return resources.getString(R.string.git_unknown_error)
|
||||||
return when (val rootCause = rootCause(throwable)) {
|
return when (val rootCause = rootCause(throwable)) {
|
||||||
is GitException -> rootCause.message
|
is GitException -> rootCause.message
|
||||||
is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message)
|
is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message)
|
||||||
else -> throwable.message ?: resources.getString(R.string.git_unknown_error)
|
else -> throwable.message ?: resources.getString(R.string.git_unknown_error)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun rootCause(throwable: Throwable): Throwable {
|
private fun rootCause(throwable: Throwable): Throwable {
|
||||||
var cause = throwable
|
var cause = throwable
|
||||||
while (cause.cause != null) {
|
while (cause.cause != null) {
|
||||||
if (cause is GitException) break
|
if (cause is GitException) break
|
||||||
val nextCause = cause.cause!!
|
val nextCause = cause.cause!!
|
||||||
if (nextCause is RemoteException) break
|
if (nextCause is RemoteException) break
|
||||||
cause = nextCause
|
cause = nextCause
|
||||||
}
|
|
||||||
return cause
|
|
||||||
}
|
}
|
||||||
|
return cause
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,96 +26,87 @@ import org.eclipse.jgit.lib.PersonIdent
|
||||||
import org.eclipse.jgit.transport.RemoteRefUpdate
|
import org.eclipse.jgit.transport.RemoteRefUpdate
|
||||||
|
|
||||||
class GitCommandExecutor(
|
class GitCommandExecutor(
|
||||||
private val activity: FragmentActivity,
|
private val activity: FragmentActivity,
|
||||||
private val operation: GitOperation,
|
private val operation: GitOperation,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
suspend fun execute(): Result<Unit, Throwable> {
|
suspend fun execute(): Result<Unit, Throwable> {
|
||||||
val snackbar = activity.snackbar(
|
val snackbar =
|
||||||
message = activity.resources.getString(R.string.git_operation_running),
|
activity.snackbar(
|
||||||
length = Snackbar.LENGTH_INDEFINITE,
|
message = activity.resources.getString(R.string.git_operation_running),
|
||||||
)
|
length = Snackbar.LENGTH_INDEFINITE,
|
||||||
// Count the number of uncommitted files
|
)
|
||||||
var nbChanges = 0
|
// Count the number of uncommitted files
|
||||||
return runCatching {
|
var nbChanges = 0
|
||||||
for (command in operation.commands) {
|
return runCatching {
|
||||||
when (command) {
|
for (command in operation.commands) {
|
||||||
is StatusCommand -> {
|
when (command) {
|
||||||
val res = withContext(Dispatchers.IO) {
|
is StatusCommand -> {
|
||||||
command.call()
|
val res = withContext(Dispatchers.IO) { command.call() }
|
||||||
}
|
nbChanges = res.uncommittedChanges.size
|
||||||
nbChanges = res.uncommittedChanges.size
|
}
|
||||||
}
|
is CommitCommand -> {
|
||||||
is CommitCommand -> {
|
// the previous status will eventually be used to avoid a commit
|
||||||
// the previous status will eventually be used to avoid a commit
|
if (nbChanges > 0) {
|
||||||
if (nbChanges > 0) {
|
withContext(Dispatchers.IO) {
|
||||||
withContext(Dispatchers.IO) {
|
val name = GitSettings.authorName.ifEmpty { "root" }
|
||||||
val name = GitSettings.authorName.ifEmpty { "root" }
|
val email = GitSettings.authorEmail.ifEmpty { "localhost" }
|
||||||
val email = GitSettings.authorEmail.ifEmpty { "localhost" }
|
val identity = PersonIdent(name, email)
|
||||||
val identity = PersonIdent(name, email)
|
command.setAuthor(identity).setCommitter(identity).call()
|
||||||
command.setAuthor(identity).setCommitter(identity).call()
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is PullCommand -> {
|
|
||||||
val result = withContext(Dispatchers.IO) {
|
|
||||||
command.call()
|
|
||||||
}
|
|
||||||
if (result.rebaseResult != null) {
|
|
||||||
if (!result.rebaseResult.status.isSuccessful) {
|
|
||||||
throw PullException.PullRebaseFailed
|
|
||||||
}
|
|
||||||
} else if (result.mergeResult != null) {
|
|
||||||
if (!result.mergeResult.mergeStatus.isSuccessful) {
|
|
||||||
throw PullException.PullMergeFailed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is PushCommand -> {
|
|
||||||
val results = withContext(Dispatchers.IO) {
|
|
||||||
command.call()
|
|
||||||
}
|
|
||||||
for (result in results) {
|
|
||||||
// Code imported (modified) from Gerrit PushOp, license Apache v2
|
|
||||||
for (rru in result.remoteUpdates) {
|
|
||||||
when (rru.status) {
|
|
||||||
RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward
|
|
||||||
RemoteRefUpdate.Status.REJECTED_NODELETE,
|
|
||||||
RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
|
|
||||||
RemoteRefUpdate.Status.NON_EXISTING,
|
|
||||||
RemoteRefUpdate.Status.NOT_ATTEMPTED,
|
|
||||||
-> throw PushException.Generic(rru.status.name)
|
|
||||||
RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
|
|
||||||
throw if ("non-fast-forward" == rru.message) {
|
|
||||||
PushException.RemoteRejected
|
|
||||||
} else {
|
|
||||||
PushException.Generic(rru.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RemoteRefUpdate.Status.UP_TO_DATE -> {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(
|
|
||||||
activity.applicationContext,
|
|
||||||
activity.applicationContext.getString(R.string.git_push_up_to_date),
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
command.call()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.also {
|
}
|
||||||
snackbar.dismiss()
|
is PullCommand -> {
|
||||||
|
val result = withContext(Dispatchers.IO) { command.call() }
|
||||||
|
if (result.rebaseResult != null) {
|
||||||
|
if (!result.rebaseResult.status.isSuccessful) {
|
||||||
|
throw PullException.PullRebaseFailed
|
||||||
|
}
|
||||||
|
} else if (result.mergeResult != null) {
|
||||||
|
if (!result.mergeResult.mergeStatus.isSuccessful) {
|
||||||
|
throw PullException.PullMergeFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is PushCommand -> {
|
||||||
|
val results = withContext(Dispatchers.IO) { command.call() }
|
||||||
|
for (result in results) {
|
||||||
|
// Code imported (modified) from Gerrit PushOp, license Apache v2
|
||||||
|
for (rru in result.remoteUpdates) {
|
||||||
|
when (rru.status) {
|
||||||
|
RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward
|
||||||
|
RemoteRefUpdate.Status.REJECTED_NODELETE,
|
||||||
|
RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
|
||||||
|
RemoteRefUpdate.Status.NON_EXISTING,
|
||||||
|
RemoteRefUpdate.Status.NOT_ATTEMPTED, -> throw PushException.Generic(rru.status.name)
|
||||||
|
RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
|
||||||
|
throw if ("non-fast-forward" == rru.message) {
|
||||||
|
PushException.RemoteRejected
|
||||||
|
} else {
|
||||||
|
PushException.Generic(rru.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RemoteRefUpdate.Status.UP_TO_DATE -> {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
Toast.makeText(
|
||||||
|
activity.applicationContext,
|
||||||
|
activity.applicationContext.getString(R.string.git_push_up_to_date),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
withContext(Dispatchers.IO) { command.call() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.also { snackbar.dismiss() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,41 +15,37 @@ import org.eclipse.jgit.api.Git
|
||||||
import org.eclipse.jgit.revwalk.RevCommit
|
import org.eclipse.jgit.revwalk.RevCommit
|
||||||
|
|
||||||
private fun commits(): Iterable<RevCommit> {
|
private fun commits(): Iterable<RevCommit> {
|
||||||
val repo = PasswordRepository.getRepository(null)
|
val repo = PasswordRepository.getRepository(null)
|
||||||
if (repo == null) {
|
if (repo == null) {
|
||||||
e { "Could not access git repository" }
|
e { "Could not access git repository" }
|
||||||
return listOf()
|
return listOf()
|
||||||
}
|
}
|
||||||
return runCatching {
|
return runCatching { Git(repo).log().call() }.getOrElse { e ->
|
||||||
Git(repo).log().call()
|
e(e) { "Failed to obtain git commits" }
|
||||||
}.getOrElse { e ->
|
listOf()
|
||||||
e(e) { "Failed to obtain git commits" }
|
}
|
||||||
listOf()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides [GitCommit]s from a git-log of the password git repository.
|
* Provides [GitCommit] s from a git-log of the password git repository.
|
||||||
*
|
*
|
||||||
* All commits are acquired on the first request to this object.
|
* All commits are acquired on the first request to this object.
|
||||||
*/
|
*/
|
||||||
class GitLogModel {
|
class GitLogModel {
|
||||||
|
|
||||||
// All commits are acquired here at once. Acquiring the commits in batches would not have been
|
// All commits are acquired here at once. Acquiring the commits in batches would not have been
|
||||||
// entirely sensible because the amount of computation required to obtain commit number n from
|
// entirely sensible because the amount of computation required to obtain commit number n from
|
||||||
// the log includes the amount of computation required to obtain commit number n-1 from the log.
|
// the log includes the amount of computation required to obtain commit number n-1 from the log.
|
||||||
// This is because the commit graph is walked from HEAD to the last commit to obtain.
|
// This is because the commit graph is walked from HEAD to the last commit to obtain.
|
||||||
// Additionally, tests with 1000 commits in the log have not produced a significant delay in the
|
// Additionally, tests with 1000 commits in the log have not produced a significant delay in the
|
||||||
// user experience.
|
// user experience.
|
||||||
private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) {
|
private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
commits().map {
|
commits().map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }.toMutableList()
|
||||||
GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time)
|
}
|
||||||
}.toMutableList()
|
val size = cache.size
|
||||||
}
|
|
||||||
val size = cache.size
|
|
||||||
|
|
||||||
fun get(index: Int): GitCommit? {
|
fun get(index: Int): GitCommit? {
|
||||||
if (index >= size) e { "Cannot get git commit with index $index. There are only $size." }
|
if (index >= size) e { "Cannot get git commit with index $index. There are only $size." }
|
||||||
return cache.getOrNull(index)
|
return cache.getOrNull(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,44 +13,45 @@ import org.eclipse.jgit.lib.RepositoryState
|
||||||
|
|
||||||
class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
|
class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
|
||||||
|
|
||||||
private val merging = repository.repositoryState == RepositoryState.MERGING
|
private val merging = repository.repositoryState == RepositoryState.MERGING
|
||||||
private val resetCommands = arrayOf(
|
private val resetCommands =
|
||||||
// git checkout -b conflict-branch
|
arrayOf(
|
||||||
git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"),
|
// git checkout -b conflict-branch
|
||||||
// push the changes
|
git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"),
|
||||||
git.push().setRemote("origin"),
|
// push the changes
|
||||||
// switch back to ${gitBranch}
|
git.push().setRemote("origin"),
|
||||||
git.checkout().setName(remoteBranch),
|
// switch back to ${gitBranch}
|
||||||
|
git.checkout().setName(remoteBranch),
|
||||||
)
|
)
|
||||||
|
|
||||||
override val commands by lazy(LazyThreadSafetyMode.NONE) {
|
override val commands by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
if (merging) {
|
if (merging) {
|
||||||
// We need to run some non-command operations first
|
// We need to run some non-command operations first
|
||||||
repository.writeMergeCommitMsg(null)
|
repository.writeMergeCommitMsg(null)
|
||||||
repository.writeMergeHeads(null)
|
repository.writeMergeHeads(null)
|
||||||
arrayOf(
|
arrayOf(
|
||||||
// reset hard back to our local HEAD
|
// reset hard back to our local HEAD
|
||||||
git.reset().setMode(ResetCommand.ResetType.HARD),
|
git.reset().setMode(ResetCommand.ResetType.HARD),
|
||||||
*resetCommands,
|
*resetCommands,
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
arrayOf(
|
|
||||||
// abort the rebase
|
|
||||||
git.rebase().setOperation(RebaseCommand.Operation.ABORT),
|
|
||||||
*resetCommands,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun preExecute() = if (!git.repository.repositoryState.isRebasing && !merging) {
|
|
||||||
MaterialAlertDialogBuilder(callingActivity)
|
|
||||||
.setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
|
|
||||||
.setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded))
|
|
||||||
.setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ ->
|
|
||||||
callingActivity.finish()
|
|
||||||
}.show()
|
|
||||||
false
|
|
||||||
} else {
|
} else {
|
||||||
true
|
arrayOf(
|
||||||
|
// abort the rebase
|
||||||
|
git.rebase().setOperation(RebaseCommand.Operation.ABORT),
|
||||||
|
*resetCommands,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun preExecute() =
|
||||||
|
if (!git.repository.repositoryState.isRebasing && !merging) {
|
||||||
|
MaterialAlertDialogBuilder(callingActivity)
|
||||||
|
.setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
|
||||||
|
.setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded))
|
||||||
|
.setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() }
|
||||||
|
.show()
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,8 @@ import org.eclipse.jgit.api.GitCommand
|
||||||
*/
|
*/
|
||||||
class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) {
|
class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) {
|
||||||
|
|
||||||
override val commands: Array<GitCommand<out Any>> = arrayOf(
|
override val commands: Array<GitCommand<out Any>> =
|
||||||
Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri),
|
arrayOf(
|
||||||
|
Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,80 +24,71 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
class CredentialFinder(
|
class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) : InteractivePasswordFinder() {
|
||||||
val callingActivity: FragmentActivity,
|
|
||||||
val authMode: AuthMode
|
|
||||||
) : InteractivePasswordFinder() {
|
|
||||||
|
|
||||||
override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
|
override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
|
||||||
val gitOperationPrefs = callingActivity.getEncryptedGitPrefs()
|
val gitOperationPrefs = callingActivity.getEncryptedGitPrefs()
|
||||||
val credentialPref: String
|
val credentialPref: String
|
||||||
@StringRes val messageRes: Int
|
@StringRes val messageRes: Int
|
||||||
@StringRes val hintRes: Int
|
@StringRes val hintRes: Int
|
||||||
@StringRes val rememberRes: Int
|
@StringRes val rememberRes: Int
|
||||||
@StringRes val errorRes: Int
|
@StringRes val errorRes: Int
|
||||||
when (authMode) {
|
when (authMode) {
|
||||||
AuthMode.SshKey -> {
|
AuthMode.SshKey -> {
|
||||||
credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
|
credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
|
||||||
messageRes = R.string.passphrase_dialog_text
|
messageRes = R.string.passphrase_dialog_text
|
||||||
hintRes = R.string.ssh_keygen_passphrase
|
hintRes = R.string.ssh_keygen_passphrase
|
||||||
rememberRes = R.string.git_operation_remember_passphrase
|
rememberRes = R.string.git_operation_remember_passphrase
|
||||||
errorRes = R.string.git_operation_wrong_passphrase
|
errorRes = R.string.git_operation_wrong_passphrase
|
||||||
}
|
}
|
||||||
AuthMode.Password -> {
|
AuthMode.Password -> {
|
||||||
// Could be either an SSH or an HTTPS password
|
// Could be either an SSH or an HTTPS password
|
||||||
credentialPref = PreferenceKeys.HTTPS_PASSWORD
|
credentialPref = PreferenceKeys.HTTPS_PASSWORD
|
||||||
messageRes = R.string.password_dialog_text
|
messageRes = R.string.password_dialog_text
|
||||||
hintRes = R.string.git_operation_hint_password
|
hintRes = R.string.git_operation_hint_password
|
||||||
rememberRes = R.string.git_operation_remember_password
|
rememberRes = R.string.git_operation_remember_password
|
||||||
errorRes = R.string.git_operation_wrong_password
|
errorRes = R.string.git_operation_wrong_password
|
||||||
}
|
}
|
||||||
else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords")
|
else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords")
|
||||||
}
|
|
||||||
if (isRetry)
|
|
||||||
gitOperationPrefs.edit { remove(credentialPref) }
|
|
||||||
val storedCredential = gitOperationPrefs.getString(credentialPref, null)
|
|
||||||
if (storedCredential == null) {
|
|
||||||
val layoutInflater = LayoutInflater.from(callingActivity)
|
|
||||||
|
|
||||||
@SuppressLint("InflateParams")
|
|
||||||
val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
|
|
||||||
val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
|
|
||||||
val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
|
|
||||||
editCredential.setHint(hintRes)
|
|
||||||
val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
|
|
||||||
rememberCredential.setText(rememberRes)
|
|
||||||
if (isRetry) {
|
|
||||||
credentialLayout.error = callingActivity.resources.getString(errorRes)
|
|
||||||
// Reset error when user starts entering a password
|
|
||||||
editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
|
|
||||||
}
|
|
||||||
MaterialAlertDialogBuilder(callingActivity).run {
|
|
||||||
setTitle(R.string.passphrase_dialog_title)
|
|
||||||
setMessage(messageRes)
|
|
||||||
setView(dialogView)
|
|
||||||
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
|
||||||
val credential = editCredential.text.toString()
|
|
||||||
if (rememberCredential.isChecked) {
|
|
||||||
gitOperationPrefs.edit {
|
|
||||||
putString(credentialPref, credential)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cont.resume(credential)
|
|
||||||
}
|
|
||||||
setNegativeButton(R.string.dialog_cancel) { _, _ ->
|
|
||||||
cont.resume(null)
|
|
||||||
}
|
|
||||||
setOnCancelListener {
|
|
||||||
cont.resume(null)
|
|
||||||
}
|
|
||||||
create()
|
|
||||||
}.run {
|
|
||||||
requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential)
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cont.resume(storedCredential)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (isRetry) gitOperationPrefs.edit { remove(credentialPref) }
|
||||||
|
val storedCredential = gitOperationPrefs.getString(credentialPref, null)
|
||||||
|
if (storedCredential == null) {
|
||||||
|
val layoutInflater = LayoutInflater.from(callingActivity)
|
||||||
|
|
||||||
|
@SuppressLint("InflateParams") val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
|
||||||
|
val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
|
||||||
|
val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
|
||||||
|
editCredential.setHint(hintRes)
|
||||||
|
val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
|
||||||
|
rememberCredential.setText(rememberRes)
|
||||||
|
if (isRetry) {
|
||||||
|
credentialLayout.error = callingActivity.resources.getString(errorRes)
|
||||||
|
// Reset error when user starts entering a password
|
||||||
|
editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
|
||||||
|
}
|
||||||
|
MaterialAlertDialogBuilder(callingActivity)
|
||||||
|
.run {
|
||||||
|
setTitle(R.string.passphrase_dialog_title)
|
||||||
|
setMessage(messageRes)
|
||||||
|
setView(dialogView)
|
||||||
|
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||||
|
val credential = editCredential.text.toString()
|
||||||
|
if (rememberCredential.isChecked) {
|
||||||
|
gitOperationPrefs.edit { putString(credentialPref, credential) }
|
||||||
|
}
|
||||||
|
cont.resume(credential)
|
||||||
|
}
|
||||||
|
setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) }
|
||||||
|
setOnCancelListener { cont.resume(null) }
|
||||||
|
create()
|
||||||
|
}
|
||||||
|
.run {
|
||||||
|
requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential)
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cont.resume(storedCredential)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,170 +50,167 @@ import org.eclipse.jgit.transport.URIish
|
||||||
*/
|
*/
|
||||||
abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
||||||
|
|
||||||
abstract val commands: Array<GitCommand<out Any>>
|
abstract val commands: Array<GitCommand<out Any>>
|
||||||
private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
|
private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
|
||||||
private var sshSessionFactory: SshjSessionFactory? = null
|
private var sshSessionFactory: SshjSessionFactory? = null
|
||||||
|
|
||||||
protected val repository = PasswordRepository.getRepository(null)!!
|
protected val repository = PasswordRepository.getRepository(null)!!
|
||||||
protected val git = Git(repository)
|
protected val git = Git(repository)
|
||||||
protected val remoteBranch = GitSettings.branch
|
protected val remoteBranch = GitSettings.branch
|
||||||
private val authActivity get() = callingActivity as ContinuationContainerActivity
|
private val authActivity
|
||||||
|
get() = callingActivity as ContinuationContainerActivity
|
||||||
|
|
||||||
private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() {
|
private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() {
|
||||||
|
|
||||||
private var cachedPassword: CharArray? = null
|
private var cachedPassword: CharArray? = null
|
||||||
|
|
||||||
override fun isInteractive() = true
|
override fun isInteractive() = true
|
||||||
|
|
||||||
override fun get(uri: URIish?, vararg items: CredentialItem): Boolean {
|
override fun get(uri: URIish?, vararg items: CredentialItem): Boolean {
|
||||||
for (item in items) {
|
for (item in items) {
|
||||||
when (item) {
|
when (item) {
|
||||||
is CredentialItem.Username -> item.value = uri?.user
|
is CredentialItem.Username -> item.value = uri?.user
|
||||||
is CredentialItem.Password -> {
|
is CredentialItem.Password -> {
|
||||||
item.value = cachedPassword?.clone()
|
item.value =
|
||||||
?: passwordFinder.reqPassword(null).also {
|
cachedPassword?.clone() ?: passwordFinder.reqPassword(null).also { cachedPassword = it.clone() }
|
||||||
cachedPassword = it.clone()
|
}
|
||||||
}
|
else -> UnsupportedCredentialItem(uri, item.javaClass.name)
|
||||||
}
|
}
|
||||||
else -> UnsupportedCredentialItem(uri, item.javaClass.name)
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun supports(vararg items: CredentialItem) =
|
||||||
|
items.all { it is CredentialItem.Username || it is CredentialItem.Password }
|
||||||
|
|
||||||
|
override fun reset(uri: URIish?) {
|
||||||
|
cachedPassword?.fill(0.toChar())
|
||||||
|
cachedPassword = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSshKey(make: Boolean) {
|
||||||
|
runCatching {
|
||||||
|
val intent =
|
||||||
|
if (make) {
|
||||||
|
Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java)
|
||||||
|
} else {
|
||||||
|
Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java)
|
||||||
|
}
|
||||||
|
callingActivity.startActivity(intent)
|
||||||
|
}
|
||||||
|
.onFailure { e -> e(e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) {
|
||||||
|
sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
|
||||||
|
commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
|
||||||
|
command.setTransportConfigCallback { transport: Transport ->
|
||||||
|
(transport as? SshTransport)?.sshSessionFactory = sshSessionFactory
|
||||||
|
credentialsProvider?.let { transport.credentialsProvider = it }
|
||||||
|
}
|
||||||
|
command.setTimeout(CONNECT_TIMEOUT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Executes the GitCommand in an async task. */
|
||||||
|
suspend fun execute(): Result<Unit, Throwable> {
|
||||||
|
if (!preExecute()) {
|
||||||
|
return Ok(Unit)
|
||||||
|
}
|
||||||
|
val operationResult =
|
||||||
|
GitCommandExecutor(
|
||||||
|
callingActivity,
|
||||||
|
this,
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
postExecute()
|
||||||
|
return operationResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onMissingSshKeyFile() {
|
||||||
|
MaterialAlertDialogBuilder(callingActivity)
|
||||||
|
.setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
|
||||||
|
.setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
|
||||||
|
.setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ ->
|
||||||
|
getSshKey(false)
|
||||||
|
}
|
||||||
|
.setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ ->
|
||||||
|
getSshKey(true)
|
||||||
|
}
|
||||||
|
.setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
|
||||||
|
// Finish the blank GitActivity so user doesn't have to press back
|
||||||
|
callingActivity.finish()
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
|
||||||
|
when (authMode) {
|
||||||
|
AuthMode.SshKey ->
|
||||||
|
if (SshKey.exists) {
|
||||||
|
if (SshKey.mustAuthenticate) {
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
|
||||||
|
BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
|
||||||
|
if (it !is BiometricAuthenticator.Result.Failure) cont.resume(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
when (result) {
|
||||||
}
|
is BiometricAuthenticator.Result.Success -> {
|
||||||
|
registerAuthProviders(SshAuthMethod.SshKey(authActivity))
|
||||||
override fun supports(vararg items: CredentialItem) = items.all {
|
}
|
||||||
it is CredentialItem.Username || it is CredentialItem.Password
|
is BiometricAuthenticator.Result.Cancelled -> {
|
||||||
}
|
|
||||||
|
|
||||||
override fun reset(uri: URIish?) {
|
|
||||||
cachedPassword?.fill(0.toChar())
|
|
||||||
cachedPassword = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSshKey(make: Boolean) {
|
|
||||||
runCatching {
|
|
||||||
val intent = if (make) {
|
|
||||||
Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java)
|
|
||||||
} else {
|
|
||||||
Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java)
|
|
||||||
}
|
|
||||||
callingActivity.startActivity(intent)
|
|
||||||
}.onFailure { e ->
|
|
||||||
e(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) {
|
|
||||||
sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
|
|
||||||
commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
|
|
||||||
command.setTransportConfigCallback { transport: Transport ->
|
|
||||||
(transport as? SshTransport)?.sshSessionFactory = sshSessionFactory
|
|
||||||
credentialsProvider?.let { transport.credentialsProvider = it }
|
|
||||||
}
|
|
||||||
command.setTimeout(CONNECT_TIMEOUT)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes the GitCommand in an async task.
|
|
||||||
*/
|
|
||||||
suspend fun execute(): Result<Unit, Throwable> {
|
|
||||||
if (!preExecute()) {
|
|
||||||
return Ok(Unit)
|
|
||||||
}
|
|
||||||
val operationResult = GitCommandExecutor(
|
|
||||||
callingActivity,
|
|
||||||
this,
|
|
||||||
).execute()
|
|
||||||
postExecute()
|
|
||||||
return operationResult
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onMissingSshKeyFile() {
|
|
||||||
MaterialAlertDialogBuilder(callingActivity)
|
|
||||||
.setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
|
|
||||||
.setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
|
|
||||||
.setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ ->
|
|
||||||
getSshKey(false)
|
|
||||||
}
|
|
||||||
.setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ ->
|
|
||||||
getSshKey(true)
|
|
||||||
}
|
|
||||||
.setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
|
|
||||||
// Finish the blank GitActivity so user doesn't have to press back
|
|
||||||
callingActivity.finish()
|
|
||||||
}.show()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
|
|
||||||
when (authMode) {
|
|
||||||
AuthMode.SshKey -> if (SshKey.exists) {
|
|
||||||
if (SshKey.mustAuthenticate) {
|
|
||||||
val result = withContext(Dispatchers.Main) {
|
|
||||||
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
|
|
||||||
BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
|
|
||||||
if (it !is BiometricAuthenticator.Result.Failure)
|
|
||||||
cont.resume(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when (result) {
|
|
||||||
is BiometricAuthenticator.Result.Success -> {
|
|
||||||
registerAuthProviders(SshAuthMethod.SshKey(authActivity))
|
|
||||||
}
|
|
||||||
is BiometricAuthenticator.Result.Cancelled -> {
|
|
||||||
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
|
||||||
}
|
|
||||||
is BiometricAuthenticator.Result.Failure -> {
|
|
||||||
throw IllegalStateException("Biometric authentication failures should be ignored")
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// There is a chance we succeed if the user recently confirmed
|
|
||||||
// their screen lock. Doing so would have a potential to confuse
|
|
||||||
// users though, who might deduce that the screen lock
|
|
||||||
// protection is not effective. Hence, we fail with an error.
|
|
||||||
Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show()
|
|
||||||
callingActivity.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
registerAuthProviders(SshAuthMethod.SshKey(authActivity))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
onMissingSshKeyFile()
|
|
||||||
// This would correctly cancel the operation but won't surface a user-visible
|
|
||||||
// error, allowing users to make the SSH key selection.
|
|
||||||
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
||||||
|
}
|
||||||
|
is BiometricAuthenticator.Result.Failure -> {
|
||||||
|
throw IllegalStateException("Biometric authentication failures should be ignored")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// There is a chance we succeed if the user recently confirmed
|
||||||
|
// their screen lock. Doing so would have a potential to confuse
|
||||||
|
// users though, who might deduce that the screen lock
|
||||||
|
// protection is not effective. Hence, we fail with an error.
|
||||||
|
Toast.makeText(
|
||||||
|
callingActivity.applicationContext,
|
||||||
|
R.string.biometric_auth_generic_failure,
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
)
|
||||||
|
.show()
|
||||||
|
callingActivity.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity))
|
} else {
|
||||||
AuthMode.Password -> {
|
registerAuthProviders(SshAuthMethod.SshKey(authActivity))
|
||||||
val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password))
|
}
|
||||||
registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider)
|
} else {
|
||||||
}
|
onMissingSshKeyFile()
|
||||||
AuthMode.None -> {
|
// This would correctly cancel the operation but won't surface a user-visible
|
||||||
}
|
// error, allowing users to make the SSH key selection.
|
||||||
|
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
||||||
}
|
}
|
||||||
return execute()
|
AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity))
|
||||||
|
AuthMode.Password -> {
|
||||||
|
val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password))
|
||||||
|
registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider)
|
||||||
|
}
|
||||||
|
AuthMode.None -> {}
|
||||||
}
|
}
|
||||||
|
return execute()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** Called before execution of the Git operation. Return false to cancel. */
|
||||||
* Called before execution of the Git operation.
|
open fun preExecute() = true
|
||||||
* Return false to cancel.
|
|
||||||
*/
|
|
||||||
open fun preExecute() = true
|
|
||||||
|
|
||||||
private suspend fun postExecute() {
|
private suspend fun postExecute() {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) { sshSessionFactory?.close() }
|
||||||
sshSessionFactory?.close()
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
/**
|
/** Timeout in seconds before [TransportCommand] will abort a stalled IO operation. */
|
||||||
* Timeout in seconds before [TransportCommand] will abort a stalled IO operation.
|
private const val CONNECT_TIMEOUT = 10
|
||||||
*/
|
}
|
||||||
private const val CONNECT_TIMEOUT = 10
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,27 +8,28 @@ import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
|
||||||
import org.eclipse.jgit.api.GitCommand
|
import org.eclipse.jgit.api.GitCommand
|
||||||
|
|
||||||
class PullOperation(
|
class PullOperation(
|
||||||
callingActivity: ContinuationContainerActivity,
|
callingActivity: ContinuationContainerActivity,
|
||||||
rebase: Boolean,
|
rebase: Boolean,
|
||||||
) : GitOperation(callingActivity) {
|
) : GitOperation(callingActivity) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The story of why the pull operation is committing files goes like this: Once upon a time when
|
* The story of why the pull operation is committing files goes like this: Once upon a time when
|
||||||
* the world was burning and Blade Runner 2049 was real life (in the worst way), we were made
|
* the world was burning and Blade Runner 2049 was real life (in the worst way), we were made
|
||||||
* aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing.
|
* aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing.
|
||||||
* So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation]
|
* So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation]
|
||||||
* and then a [PushOperation]. To make the behavior identical despite this suboptimal situation,
|
* and then a [PushOperation]. To make the behavior identical despite this suboptimal situation,
|
||||||
* we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
|
* we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
|
||||||
* replicating [SyncOperation] but leaving the pushing part to [PushOperation].
|
* replicating [SyncOperation] but leaving the pushing part to [PushOperation].
|
||||||
*/
|
*/
|
||||||
override val commands: Array<GitCommand<out Any>> = arrayOf(
|
override val commands: Array<GitCommand<out Any>> =
|
||||||
// Stage all files
|
arrayOf(
|
||||||
git.add().addFilepattern("."),
|
// Stage all files
|
||||||
// Populate the changed files count
|
git.add().addFilepattern("."),
|
||||||
git.status(),
|
// Populate the changed files count
|
||||||
// Commit everything! If needed, obviously.
|
git.status(),
|
||||||
git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
|
// Commit everything! If needed, obviously.
|
||||||
// Pull and rebase on top of the remote branch
|
git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
|
||||||
git.pull().setRebase(rebase).setRemote("origin"),
|
// Pull and rebase on top of the remote branch
|
||||||
|
git.pull().setRebase(rebase).setRemote("origin"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,8 @@ import org.eclipse.jgit.api.GitCommand
|
||||||
|
|
||||||
class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
|
class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
|
||||||
|
|
||||||
override val commands: Array<GitCommand<out Any>> = arrayOf(
|
override val commands: Array<GitCommand<out Any>> =
|
||||||
git.push().setPushAll().setRemote("origin"),
|
arrayOf(
|
||||||
|
git.push().setPushAll().setRemote("origin"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,15 +9,18 @@ import org.eclipse.jgit.api.ResetCommand
|
||||||
|
|
||||||
class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
|
class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
|
||||||
|
|
||||||
override val commands = arrayOf(
|
override val commands =
|
||||||
// Stage all files
|
arrayOf(
|
||||||
git.add().addFilepattern("."),
|
// Stage all files
|
||||||
// Fetch everything from the origin remote
|
git.add().addFilepattern("."),
|
||||||
git.fetch().setRemote("origin"),
|
// Fetch everything from the origin remote
|
||||||
// Do a hard reset to the remote branch. Equivalent to git reset --hard origin/$remoteBranch
|
git.fetch().setRemote("origin"),
|
||||||
git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD),
|
// Do a hard reset to the remote branch. Equivalent to git reset --hard
|
||||||
// Force-create $remoteBranch if it doesn't exist. This covers the case where you switched
|
// origin/$remoteBranch
|
||||||
// branches from 'master' to anything else.
|
git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD),
|
||||||
git.branchCreate().setName(remoteBranch).setForce(true),
|
// Force-create $remoteBranch if it doesn't exist. This covers the case where you
|
||||||
|
// switched
|
||||||
|
// branches from 'master' to anything else.
|
||||||
|
git.branchCreate().setName(remoteBranch).setForce(true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,20 +7,21 @@ package dev.msfjarvis.aps.util.git.operation
|
||||||
import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
|
import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
|
||||||
|
|
||||||
class SyncOperation(
|
class SyncOperation(
|
||||||
callingActivity: ContinuationContainerActivity,
|
callingActivity: ContinuationContainerActivity,
|
||||||
rebase: Boolean,
|
rebase: Boolean,
|
||||||
) : GitOperation(callingActivity) {
|
) : GitOperation(callingActivity) {
|
||||||
|
|
||||||
override val commands = arrayOf(
|
override val commands =
|
||||||
// Stage all files
|
arrayOf(
|
||||||
git.add().addFilepattern("."),
|
// Stage all files
|
||||||
// Populate the changed files count
|
git.add().addFilepattern("."),
|
||||||
git.status(),
|
// Populate the changed files count
|
||||||
// Commit everything! If needed, obviously.
|
git.status(),
|
||||||
git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
|
// Commit everything! If needed, obviously.
|
||||||
// Pull and rebase on top of the remote branch
|
git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
|
||||||
git.pull().setRebase(rebase).setRemote("origin"),
|
// Pull and rebase on top of the remote branch
|
||||||
// Push it all back
|
git.pull().setRebase(rebase).setRemote("origin"),
|
||||||
git.push().setPushAll().setRemote("origin"),
|
// Push it all back
|
||||||
|
git.push().setPushAll().setRemote("origin"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,24 +14,21 @@ import kotlin.coroutines.resumeWithException
|
||||||
import net.schmizz.sshj.common.DisconnectReason
|
import net.schmizz.sshj.common.DisconnectReason
|
||||||
import net.schmizz.sshj.userauth.UserAuthException
|
import net.schmizz.sshj.userauth.UserAuthException
|
||||||
|
|
||||||
/**
|
/** Workaround for https://msfjarvis.dev/aps/issue/1164 */
|
||||||
* Workaround for https://msfjarvis.dev/aps/issue/1164
|
|
||||||
*/
|
|
||||||
open class ContinuationContainerActivity : AppCompatActivity {
|
open class ContinuationContainerActivity : AppCompatActivity {
|
||||||
|
|
||||||
constructor() : super()
|
constructor() : super()
|
||||||
constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
|
constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
|
||||||
|
|
||||||
var stashedCont: Continuation<Intent>? = null
|
var stashedCont: Continuation<Intent>? = null
|
||||||
|
|
||||||
val continueAfterUserInteraction = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
|
val continueAfterUserInteraction =
|
||||||
stashedCont?.let { cont ->
|
registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
|
||||||
stashedCont = null
|
stashedCont?.let { cont ->
|
||||||
val data = result.data
|
stashedCont = null
|
||||||
if (data != null)
|
val data = result.data
|
||||||
cont.resume(data)
|
if (data != null) cont.resume(data)
|
||||||
else
|
else cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
||||||
cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,162 +38,170 @@ import org.openintents.ssh.authentication.response.Response
|
||||||
import org.openintents.ssh.authentication.response.SigningResponse
|
import org.openintents.ssh.authentication.response.SigningResponse
|
||||||
import org.openintents.ssh.authentication.response.SshPublicKeyResponse
|
import org.openintents.ssh.authentication.response.SshPublicKeyResponse
|
||||||
|
|
||||||
class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : KeyProvider, Closeable {
|
class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) :
|
||||||
|
KeyProvider, Closeable {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
suspend fun prepareAndUse(activity: ContinuationContainerActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) {
|
suspend fun prepareAndUse(
|
||||||
withContext(Dispatchers.Main) {
|
activity: ContinuationContainerActivity,
|
||||||
OpenKeychainKeyProvider(activity)
|
block: (provider: OpenKeychainKeyProvider) -> Unit
|
||||||
}.prepareAndUse(block)
|
) {
|
||||||
}
|
withContext(Dispatchers.Main) { OpenKeychainKeyProvider(activity) }.prepareAndUse(block)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class ApiResponse {
|
private sealed class ApiResponse {
|
||||||
data class Success(val response: Response) : ApiResponse()
|
data class Success(val response: Response) : ApiResponse()
|
||||||
data class GeneralError(val exception: Exception) : ApiResponse()
|
data class GeneralError(val exception: Exception) : ApiResponse()
|
||||||
data class NoSuchKey(val exception: Exception) : ApiResponse()
|
data class NoSuchKey(val exception: Exception) : ApiResponse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val context = activity.applicationContext
|
||||||
|
private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER)
|
||||||
|
private val preferences = context.sharedPrefs
|
||||||
|
private lateinit var sshServiceApi: SshAuthenticationApi
|
||||||
|
|
||||||
|
private var keyId
|
||||||
|
get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)
|
||||||
|
set(value) {
|
||||||
|
preferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) }
|
||||||
}
|
}
|
||||||
|
private var publicKey: PublicKey? = null
|
||||||
|
private var privateKey: OpenKeychainPrivateKey? = null
|
||||||
|
|
||||||
private val context = activity.applicationContext
|
private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) {
|
||||||
private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER)
|
prepare()
|
||||||
private val preferences = context.sharedPrefs
|
use(block)
|
||||||
private lateinit var sshServiceApi: SshAuthenticationApi
|
}
|
||||||
|
|
||||||
private var keyId
|
private suspend fun prepare() {
|
||||||
get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)
|
sshServiceApi =
|
||||||
set(value) {
|
suspendCoroutine { cont ->
|
||||||
preferences.edit {
|
sshServiceConnection.connect(
|
||||||
putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value)
|
object : SshAuthenticationConnection.OnBound {
|
||||||
|
override fun onBound(sshAgent: ISshAuthenticationService) {
|
||||||
|
d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" }
|
||||||
|
cont.resume(SshAuthenticationApi(context, sshAgent))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
private var publicKey: PublicKey? = null
|
|
||||||
private var privateKey: OpenKeychainPrivateKey? = null
|
|
||||||
|
|
||||||
private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) {
|
override fun onError() {
|
||||||
prepare()
|
throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
|
||||||
use(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun prepare() {
|
|
||||||
sshServiceApi = suspendCoroutine { cont ->
|
|
||||||
sshServiceConnection.connect(object : SshAuthenticationConnection.OnBound {
|
|
||||||
override fun onBound(sshAgent: ISshAuthenticationService) {
|
|
||||||
d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" }
|
|
||||||
cont.resume(SshAuthenticationApi(context, sshAgent))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onError() {
|
|
||||||
throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (keyId == null) {
|
|
||||||
selectKey()
|
|
||||||
}
|
|
||||||
check(keyId != null)
|
|
||||||
fetchPublicKey()
|
|
||||||
makePrivateKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fetchPublicKey(isRetry: Boolean = false) {
|
|
||||||
when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) {
|
|
||||||
is ApiResponse.Success -> {
|
|
||||||
val response = sshPublicKeyResponse.response as SshPublicKeyResponse
|
|
||||||
val sshPublicKey = response.sshPublicKey!!
|
|
||||||
publicKey = parseSshPublicKey(sshPublicKey)
|
|
||||||
?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
|
|
||||||
}
|
}
|
||||||
is ApiResponse.NoSuchKey -> if (isRetry) {
|
}
|
||||||
throw sshPublicKeyResponse.exception
|
)
|
||||||
} else {
|
}
|
||||||
// Allow the user to reselect an authentication key and retry
|
|
||||||
selectKey()
|
if (keyId == null) {
|
||||||
fetchPublicKey(true)
|
selectKey()
|
||||||
|
}
|
||||||
|
check(keyId != null)
|
||||||
|
fetchPublicKey()
|
||||||
|
makePrivateKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchPublicKey(isRetry: Boolean = false) {
|
||||||
|
when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) {
|
||||||
|
is ApiResponse.Success -> {
|
||||||
|
val response = sshPublicKeyResponse.response as SshPublicKeyResponse
|
||||||
|
val sshPublicKey = response.sshPublicKey!!
|
||||||
|
publicKey =
|
||||||
|
parseSshPublicKey(sshPublicKey) ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
|
||||||
|
}
|
||||||
|
is ApiResponse.NoSuchKey ->
|
||||||
|
if (isRetry) {
|
||||||
|
throw sshPublicKeyResponse.exception
|
||||||
|
} else {
|
||||||
|
// Allow the user to reselect an authentication key and retry
|
||||||
|
selectKey()
|
||||||
|
fetchPublicKey(true)
|
||||||
|
}
|
||||||
|
is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun selectKey() {
|
||||||
|
when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) {
|
||||||
|
is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId
|
||||||
|
is ApiResponse.GeneralError -> throw keySelectionResponse.exception
|
||||||
|
is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse {
|
||||||
|
d { "executeRequest($request) called" }
|
||||||
|
val result =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
// If the request required user interaction, the data returned from the
|
||||||
|
// PendingIntent
|
||||||
|
// is used as the real request.
|
||||||
|
sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!!
|
||||||
|
}
|
||||||
|
return parseResult(request, result).also { d { "executeRequest($request): $it" } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun parseResult(request: Request, result: Intent): ApiResponse {
|
||||||
|
return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) {
|
||||||
|
SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
|
||||||
|
ApiResponse.Success(
|
||||||
|
when (request) {
|
||||||
|
is KeySelectionRequest -> KeySelectionResponse(result)
|
||||||
|
is SshPublicKeyRequest -> SshPublicKeyResponse(result)
|
||||||
|
is SigningRequest -> SigningResponse(result)
|
||||||
|
else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||||
|
val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
|
||||||
|
val resultOfUserInteraction: Intent =
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
suspendCoroutine { cont ->
|
||||||
|
activity.stashedCont = cont
|
||||||
|
activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build())
|
||||||
}
|
}
|
||||||
is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception
|
}
|
||||||
|
executeApiRequest(request, resultOfUserInteraction)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
|
||||||
|
val exception =
|
||||||
|
UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}")
|
||||||
|
when (error?.error) {
|
||||||
|
SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY ->
|
||||||
|
ApiResponse.NoSuchKey(exception)
|
||||||
|
else -> ApiResponse.GeneralError(exception)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun selectKey() {
|
private fun makePrivateKey() {
|
||||||
when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) {
|
check(keyId != null && publicKey != null)
|
||||||
is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId
|
privateKey =
|
||||||
is ApiResponse.GeneralError -> throw keySelectionResponse.exception
|
object : OpenKeychainPrivateKey {
|
||||||
is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception
|
override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
|
||||||
}
|
when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) {
|
||||||
|
is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
|
||||||
|
is ApiResponse.GeneralError -> throw signingResponse.exception
|
||||||
|
is ApiResponse.NoSuchKey -> throw signingResponse.exception
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAlgorithm() = publicKey!!.algorithm
|
||||||
|
override fun getParams() = (publicKey as? ECKey)?.params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
activity.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.Main) { activity.continueAfterUserInteraction.unregister() }
|
||||||
}
|
}
|
||||||
|
sshServiceConnection.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse {
|
override fun getPrivate() = privateKey
|
||||||
d { "executeRequest($request) called" }
|
|
||||||
val result = withContext(Dispatchers.Main) {
|
|
||||||
// If the request required user interaction, the data returned from the PendingIntent
|
|
||||||
// is used as the real request.
|
|
||||||
sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!!
|
|
||||||
}
|
|
||||||
return parseResult(request, result).also {
|
|
||||||
d { "executeRequest($request): $it" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun parseResult(request: Request, result: Intent): ApiResponse {
|
override fun getPublic() = publicKey
|
||||||
return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) {
|
|
||||||
SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
|
|
||||||
ApiResponse.Success(when (request) {
|
|
||||||
is KeySelectionRequest -> KeySelectionResponse(result)
|
|
||||||
is SshPublicKeyRequest -> SshPublicKeyResponse(result)
|
|
||||||
is SigningRequest -> SigningResponse(result)
|
|
||||||
else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
|
||||||
val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
|
|
||||||
val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) {
|
|
||||||
suspendCoroutine { cont ->
|
|
||||||
activity.stashedCont = cont
|
|
||||||
activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
executeApiRequest(request, resultOfUserInteraction)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
|
|
||||||
val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}")
|
|
||||||
when (error?.error) {
|
|
||||||
SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception)
|
|
||||||
else -> ApiResponse.GeneralError(exception)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makePrivateKey() {
|
override fun getType(): KeyType = KeyType.fromKey(publicKey)
|
||||||
check(keyId != null && publicKey != null)
|
|
||||||
privateKey = object : OpenKeychainPrivateKey {
|
|
||||||
override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
|
|
||||||
when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) {
|
|
||||||
is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
|
|
||||||
is ApiResponse.GeneralError -> throw signingResponse.exception
|
|
||||||
is ApiResponse.NoSuchKey -> throw signingResponse.exception
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAlgorithm() = publicKey!!.algorithm
|
|
||||||
override fun getParams() = (publicKey as? ECKey)?.params
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
activity.lifecycleScope.launch {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
activity.continueAfterUserInteraction.unregister()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sshServiceConnection.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPrivate() = privateKey
|
|
||||||
|
|
||||||
override fun getPublic() = publicKey
|
|
||||||
|
|
||||||
override fun getType(): KeyType = KeyType.fromKey(publicKey)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,8 +8,6 @@ import com.hierynomus.sshj.key.KeyAlgorithm
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.security.PrivateKey
|
import java.security.PrivateKey
|
||||||
import java.security.interfaces.ECKey
|
import java.security.interfaces.ECKey
|
||||||
import java.security.interfaces.ECPrivateKey
|
|
||||||
import java.security.spec.ECParameterSpec
|
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import net.schmizz.sshj.common.Buffer
|
import net.schmizz.sshj.common.Buffer
|
||||||
import net.schmizz.sshj.common.Factory
|
import net.schmizz.sshj.common.Factory
|
||||||
|
@ -18,79 +16,83 @@ import org.openintents.ssh.authentication.SshAuthenticationApi
|
||||||
|
|
||||||
interface OpenKeychainPrivateKey : PrivateKey, ECKey {
|
interface OpenKeychainPrivateKey : PrivateKey, ECKey {
|
||||||
|
|
||||||
suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray
|
suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray
|
||||||
|
|
||||||
override fun getFormat() = null
|
override fun getFormat() = null
|
||||||
override fun getEncoded() = null
|
override fun getEncoded() = null
|
||||||
}
|
}
|
||||||
|
|
||||||
class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) : Factory.Named<KeyAlgorithm> by factory {
|
class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) :
|
||||||
|
Factory.Named<KeyAlgorithm> by factory {
|
||||||
|
|
||||||
override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create())
|
override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create())
|
||||||
}
|
}
|
||||||
|
|
||||||
class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm {
|
class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm {
|
||||||
|
|
||||||
private val hashAlgorithm = when (keyAlgorithm.keyAlgorithm) {
|
private val hashAlgorithm =
|
||||||
"rsa-sha2-512" -> SshAuthenticationApi.SHA512
|
when (keyAlgorithm.keyAlgorithm) {
|
||||||
"rsa-sha2-256" -> SshAuthenticationApi.SHA256
|
"rsa-sha2-512" -> SshAuthenticationApi.SHA512
|
||||||
"ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1
|
"rsa-sha2-256" -> SshAuthenticationApi.SHA256
|
||||||
// Other algorithms don't use this value, but it has to be valid.
|
"ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1
|
||||||
else -> SshAuthenticationApi.SHA512
|
// Other algorithms don't use this value, but it has to be valid.
|
||||||
|
else -> SshAuthenticationApi.SHA512
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
|
override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
|
||||||
}
|
}
|
||||||
|
|
||||||
class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : Signature by wrappedSignature {
|
class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) :
|
||||||
|
Signature by wrappedSignature {
|
||||||
|
|
||||||
private val data = ByteArrayOutputStream()
|
private val data = ByteArrayOutputStream()
|
||||||
|
|
||||||
private var bridgedPrivateKey: OpenKeychainPrivateKey? = null
|
private var bridgedPrivateKey: OpenKeychainPrivateKey? = null
|
||||||
|
|
||||||
override fun initSign(prvkey: PrivateKey?) {
|
override fun initSign(prvkey: PrivateKey?) {
|
||||||
if (prvkey is OpenKeychainPrivateKey) {
|
if (prvkey is OpenKeychainPrivateKey) {
|
||||||
bridgedPrivateKey = prvkey
|
bridgedPrivateKey = prvkey
|
||||||
} else {
|
|
||||||
wrappedSignature.initSign(prvkey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun update(H: ByteArray?) {
|
|
||||||
if (bridgedPrivateKey != null) {
|
|
||||||
data.write(H!!)
|
|
||||||
} else {
|
|
||||||
wrappedSignature.update(H)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun update(H: ByteArray?, off: Int, len: Int) {
|
|
||||||
if (bridgedPrivateKey != null) {
|
|
||||||
data.write(H!!, off, len)
|
|
||||||
} else {
|
|
||||||
wrappedSignature.update(H, off, len)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun sign(): ByteArray? = if (bridgedPrivateKey != null) {
|
|
||||||
runBlocking {
|
|
||||||
bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
wrappedSignature.sign()
|
wrappedSignature.initSign(prvkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(H: ByteArray?) {
|
||||||
|
if (bridgedPrivateKey != null) {
|
||||||
|
data.write(H!!)
|
||||||
|
} else {
|
||||||
|
wrappedSignature.update(H)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(H: ByteArray?, off: Int, len: Int) {
|
||||||
|
if (bridgedPrivateKey != null) {
|
||||||
|
data.write(H!!, off, len)
|
||||||
|
} else {
|
||||||
|
wrappedSignature.update(H, off, len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sign(): ByteArray? =
|
||||||
|
if (bridgedPrivateKey != null) {
|
||||||
|
runBlocking { bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) }
|
||||||
|
} else {
|
||||||
|
wrappedSignature.sign()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun encode(signature: ByteArray?): ByteArray? = if (bridgedPrivateKey != null) {
|
override fun encode(signature: ByteArray?): ByteArray? =
|
||||||
require(signature != null) { "OpenKeychain signature must not be null" }
|
if (bridgedPrivateKey != null) {
|
||||||
val encodedSignature = Buffer.PlainBuffer(signature)
|
require(signature != null) { "OpenKeychain signature must not be null" }
|
||||||
// We need to drop the algorithm name and extract the raw signature since SSHJ adds the name
|
val encodedSignature = Buffer.PlainBuffer(signature)
|
||||||
// later.
|
// We need to drop the algorithm name and extract the raw signature since SSHJ adds the
|
||||||
encodedSignature.readString()
|
// name
|
||||||
encodedSignature.readBytes().also {
|
// later.
|
||||||
bridgedPrivateKey = null
|
encodedSignature.readString()
|
||||||
data.reset()
|
encodedSignature.readBytes().also {
|
||||||
}
|
bridgedPrivateKey = null
|
||||||
|
data.reset()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
wrappedSignature.encode(signature)
|
wrappedSignature.encode(signature)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,286 +51,288 @@ private const val KEYSTORE_ALIAS = "sshkey"
|
||||||
private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs"
|
private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs"
|
||||||
|
|
||||||
private val androidKeystore: KeyStore by lazy(LazyThreadSafetyMode.NONE) {
|
private val androidKeystore: KeyStore by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
|
KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private val KeyStore.sshPrivateKey
|
private val KeyStore.sshPrivateKey
|
||||||
get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
|
get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
|
||||||
|
|
||||||
private val KeyStore.sshPublicKey
|
private val KeyStore.sshPublicKey
|
||||||
get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
|
get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
|
||||||
|
|
||||||
fun parseSshPublicKey(sshPublicKey: String): PublicKey? {
|
fun parseSshPublicKey(sshPublicKey: String): PublicKey? {
|
||||||
val sshKeyParts = sshPublicKey.split("""\s+""".toRegex())
|
val sshKeyParts = sshPublicKey.split("""\s+""".toRegex())
|
||||||
if (sshKeyParts.size < 2)
|
if (sshKeyParts.size < 2) return null
|
||||||
return null
|
return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
|
||||||
return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toSshPublicKey(publicKey: PublicKey): String {
|
fun toSshPublicKey(publicKey: PublicKey): String {
|
||||||
val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData
|
val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData
|
||||||
val keyType = KeyType.fromKey(publicKey)
|
val keyType = KeyType.fromKey(publicKey)
|
||||||
return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}"
|
return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}"
|
||||||
}
|
}
|
||||||
|
|
||||||
object SshKey {
|
object SshKey {
|
||||||
|
|
||||||
val sshPublicKey
|
val sshPublicKey
|
||||||
get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null
|
get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null
|
||||||
val canShowSshPublicKey
|
val canShowSshPublicKey
|
||||||
get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519)
|
get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519)
|
||||||
val exists
|
val exists
|
||||||
get() = type != null
|
get() = type != null
|
||||||
val mustAuthenticate: Boolean
|
val mustAuthenticate: Boolean
|
||||||
get() {
|
get() {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519))
|
if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) return false
|
||||||
return false
|
when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
|
||||||
when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
|
is PrivateKey -> {
|
||||||
is PrivateKey -> {
|
val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
|
||||||
val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
|
return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired
|
||||||
return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired
|
}
|
||||||
}
|
is SecretKey -> {
|
||||||
is SecretKey -> {
|
val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
|
||||||
val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
|
(factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired
|
||||||
(factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired
|
}
|
||||||
}
|
else -> throw IllegalStateException("SSH key does not exist in Keystore")
|
||||||
else -> throw IllegalStateException("SSH key does not exist in Keystore")
|
|
||||||
}
|
|
||||||
}.getOrElse { error ->
|
|
||||||
// It is fine to swallow the exception here since it will reappear when the key is
|
|
||||||
// used for SSH authentication and can then be shown in the UI.
|
|
||||||
d(error)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private val context: Context
|
.getOrElse { error ->
|
||||||
get() = Application.instance.applicationContext
|
// It is fine to swallow the exception here since it will reappear when the key
|
||||||
|
// is
|
||||||
private val privateKeyFile
|
// used for SSH authentication and can then be shown in the UI.
|
||||||
get() = File(context.filesDir, ".ssh_key")
|
d(error)
|
||||||
private val publicKeyFile
|
false
|
||||||
get() = File(context.filesDir, ".ssh_key.pub")
|
|
||||||
|
|
||||||
private var type: Type?
|
|
||||||
get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
|
|
||||||
set(value) = context.sharedPrefs.edit {
|
|
||||||
putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
|
||||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
|
|
||||||
else
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum class Type(val value: String) {
|
|
||||||
Imported("imported"),
|
|
||||||
KeystoreNative("keystore_native"),
|
|
||||||
KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
|
|
||||||
|
|
||||||
// Behaves like `Imported`, but allows to view the public key.
|
|
||||||
LegacyGenerated("legacy_generated"),
|
|
||||||
;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun fromValue(value: String?): Type? = values().associateBy { it.value }[value]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) {
|
private val context: Context
|
||||||
Rsa(KeyProperties.KEY_ALGORITHM_RSA, {
|
get() = Application.instance.applicationContext
|
||||||
setKeySize(3072)
|
|
||||||
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
|
private val privateKeyFile
|
||||||
setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
|
get() = File(context.filesDir, ".ssh_key")
|
||||||
}),
|
private val publicKeyFile
|
||||||
Ecdsa(KeyProperties.KEY_ALGORITHM_EC, {
|
get() = File(context.filesDir, ".ssh_key.pub")
|
||||||
setKeySize(256)
|
|
||||||
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
|
private var type: Type?
|
||||||
setDigests(KeyProperties.DIGEST_SHA256)
|
get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
set(value) = context.sharedPrefs.edit { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) }
|
||||||
setIsStrongBoxBacked(isStrongBoxSupported)
|
|
||||||
}
|
private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
}),
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||||
|
context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
|
||||||
|
else false
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class Type(val value: String) {
|
||||||
|
Imported("imported"),
|
||||||
|
KeystoreNative("keystore_native"),
|
||||||
|
KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
|
||||||
|
|
||||||
|
// Behaves like `Imported`, but allows to view the public key.
|
||||||
|
LegacyGenerated("legacy_generated"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun fromValue(value: String?): Type? = values().associateBy { it.value }[value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) {
|
||||||
|
Rsa(
|
||||||
|
KeyProperties.KEY_ALGORITHM_RSA,
|
||||||
|
{
|
||||||
|
setKeySize(3072)
|
||||||
|
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
|
||||||
|
setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Ecdsa(
|
||||||
|
KeyProperties.KEY_ALGORITHM_EC,
|
||||||
|
{
|
||||||
|
setKeySize(256)
|
||||||
|
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
|
||||||
|
setDigests(KeyProperties.DIGEST_SHA256)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
setIsStrongBoxBacked(isStrongBoxSupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun delete() {
|
||||||
|
androidKeystore.deleteEntry(KEYSTORE_ALIAS)
|
||||||
|
// Remove Tink key set used by AndroidX's EncryptedFile.
|
||||||
|
context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { clear() }
|
||||||
|
if (privateKeyFile.isFile) {
|
||||||
|
privateKeyFile.delete()
|
||||||
|
}
|
||||||
|
if (publicKeyFile.isFile) {
|
||||||
|
publicKeyFile.delete()
|
||||||
|
}
|
||||||
|
context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
|
||||||
|
type = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun import(uri: Uri) {
|
||||||
|
// First check whether the content at uri is likely an SSH private key.
|
||||||
|
val fileSize =
|
||||||
|
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
|
||||||
|
// Cursor returns only a single row.
|
||||||
|
cursor.moveToFirst()
|
||||||
|
cursor.getInt(0)
|
||||||
|
}
|
||||||
|
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
|
||||||
|
|
||||||
|
// We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
|
||||||
|
if (fileSize > 100_000 || fileSize == 0)
|
||||||
|
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
|
||||||
|
|
||||||
|
val sshKeyInputStream =
|
||||||
|
context.contentResolver.openInputStream(uri)
|
||||||
|
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
|
||||||
|
val lines = sshKeyInputStream.bufferedReader().readLines()
|
||||||
|
|
||||||
|
// The file must have more than 2 lines, and the first and last line must have private key
|
||||||
|
// markers.
|
||||||
|
if (lines.size < 2 ||
|
||||||
|
!Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
|
||||||
|
!Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
|
||||||
|
)
|
||||||
|
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
|
||||||
|
|
||||||
|
// At this point, we are reasonably confident that we have actually been provided a private
|
||||||
|
// key and delete the old key.
|
||||||
|
delete()
|
||||||
|
// Canonicalize line endings to '\n'.
|
||||||
|
privateKeyFile.writeText(lines.joinToString("\n"))
|
||||||
|
|
||||||
|
type = Type.Imported
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("To be used only in Migrations.kt")
|
||||||
|
fun useLegacyKey(isGenerated: Boolean) {
|
||||||
|
type = if (isGenerated) Type.LegacyGenerated else Type.Imported
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
|
private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
MasterKey.Builder(context, KEYSTORE_ALIAS).run {
|
||||||
|
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
setRequestStrongBoxBacked(true)
|
||||||
|
setUserAuthenticationRequired(requireAuthentication, 15)
|
||||||
|
build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun delete() {
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
androidKeystore.deleteEntry(KEYSTORE_ALIAS)
|
private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) =
|
||||||
// Remove Tink key set used by AndroidX's EncryptedFile.
|
withContext(Dispatchers.IO) {
|
||||||
context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit {
|
EncryptedFile.Builder(
|
||||||
clear()
|
context,
|
||||||
}
|
privateKeyFile,
|
||||||
if (privateKeyFile.isFile) {
|
getOrCreateWrappingMasterKey(requireAuthentication),
|
||||||
privateKeyFile.delete()
|
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
|
||||||
}
|
|
||||||
if (publicKeyFile.isFile) {
|
|
||||||
publicKeyFile.delete()
|
|
||||||
}
|
|
||||||
context.getEncryptedGitPrefs().edit {
|
|
||||||
remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
|
|
||||||
}
|
|
||||||
type = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun import(uri: Uri) {
|
|
||||||
// First check whether the content at uri is likely an SSH private key.
|
|
||||||
val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)
|
|
||||||
?.use { cursor ->
|
|
||||||
// Cursor returns only a single row.
|
|
||||||
cursor.moveToFirst()
|
|
||||||
cursor.getInt(0)
|
|
||||||
} ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
|
|
||||||
|
|
||||||
// We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
|
|
||||||
if (fileSize > 100_000 || fileSize == 0)
|
|
||||||
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
|
|
||||||
|
|
||||||
val sshKeyInputStream = context.contentResolver.openInputStream(uri)
|
|
||||||
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
|
|
||||||
val lines = sshKeyInputStream.bufferedReader().readLines()
|
|
||||||
|
|
||||||
// The file must have more than 2 lines, and the first and last line must have private key
|
|
||||||
// markers.
|
|
||||||
if (lines.size < 2 ||
|
|
||||||
!Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
|
|
||||||
!Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
|
|
||||||
)
|
)
|
||||||
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
|
.run {
|
||||||
|
setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME)
|
||||||
// At this point, we are reasonably confident that we have actually been provided a private
|
build()
|
||||||
// key and delete the old key.
|
|
||||||
delete()
|
|
||||||
// Canonicalize line endings to '\n'.
|
|
||||||
privateKeyFile.writeText(lines.joinToString("\n"))
|
|
||||||
|
|
||||||
type = Type.Imported
|
|
||||||
}
|
|
||||||
|
|
||||||
@Deprecated("To be used only in Migrations.kt")
|
|
||||||
fun useLegacyKey(isGenerated: Boolean) {
|
|
||||||
type = if (isGenerated) Type.LegacyGenerated else Type.Imported
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
|
||||||
private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
|
|
||||||
MasterKey.Builder(context, KEYSTORE_ALIAS).run {
|
|
||||||
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
|
||||||
setRequestStrongBoxBacked(true)
|
|
||||||
setUserAuthenticationRequired(requireAuthentication, 15)
|
|
||||||
build()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
|
suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) =
|
||||||
EncryptedFile.Builder(context,
|
withContext(Dispatchers.IO) {
|
||||||
privateKeyFile,
|
delete()
|
||||||
getOrCreateWrappingMasterKey(requireAuthentication),
|
|
||||||
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).run {
|
val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication)
|
||||||
setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME)
|
// Generate the ed25519 key pair and encrypt the private key.
|
||||||
build()
|
val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair()
|
||||||
}
|
encryptedPrivateKeyFile.openFileOutput().use { os -> os.write((keyPair.private as EdDSAPrivateKey).seed) }
|
||||||
|
|
||||||
|
// Write public key in SSH format to .ssh_key.pub.
|
||||||
|
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
|
||||||
|
|
||||||
|
type = Type.KeystoreWrappedEd25519
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) {
|
||||||
suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
|
delete()
|
||||||
delete()
|
|
||||||
|
|
||||||
val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication)
|
// Generate Keystore-backed private key.
|
||||||
// Generate the ed25519 key pair and encrypt the private key.
|
val parameterSpec =
|
||||||
val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair()
|
KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run {
|
||||||
encryptedPrivateKeyFile.openFileOutput().use { os ->
|
apply(algorithm.applyToSpec)
|
||||||
os.write((keyPair.private as EdDSAPrivateKey).seed)
|
if (requireAuthentication) {
|
||||||
|
setUserAuthenticationRequired(true)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(30)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
build()
|
||||||
|
}
|
||||||
|
val keyPair =
|
||||||
|
KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run {
|
||||||
|
initialize(parameterSpec)
|
||||||
|
generateKeyPair()
|
||||||
|
}
|
||||||
|
|
||||||
// Write public key in SSH format to .ssh_key.pub.
|
// Write public key in SSH format to .ssh_key.pub.
|
||||||
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
|
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
|
||||||
|
|
||||||
type = Type.KeystoreWrappedEd25519
|
type = Type.KeystoreNative
|
||||||
|
}
|
||||||
|
|
||||||
|
fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? =
|
||||||
|
when (type) {
|
||||||
|
Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder)
|
||||||
|
Type.KeystoreNative -> KeystoreNativeKeyProvider
|
||||||
|
Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider
|
||||||
|
null -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) {
|
private object KeystoreNativeKeyProvider : KeyProvider {
|
||||||
delete()
|
|
||||||
|
|
||||||
// Generate Keystore-backed private key.
|
override fun getPublic(): PublicKey =
|
||||||
val parameterSpec = KeyGenParameterSpec.Builder(
|
runCatching { androidKeystore.sshPublicKey!! }.getOrElse { error ->
|
||||||
KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN
|
e(error)
|
||||||
).run {
|
throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error)
|
||||||
apply(algorithm.applyToSpec)
|
}
|
||||||
if (requireAuthentication) {
|
|
||||||
setUserAuthenticationRequired(true)
|
override fun getPrivate(): PrivateKey =
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
runCatching { androidKeystore.sshPrivateKey!! }.getOrElse { error ->
|
||||||
setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL)
|
e(error)
|
||||||
} else {
|
throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error)
|
||||||
@Suppress("DEPRECATION")
|
}
|
||||||
setUserAuthenticationValidityDurationSeconds(30)
|
|
||||||
}
|
override fun getType(): KeyType = KeyType.fromKey(public)
|
||||||
}
|
}
|
||||||
build()
|
|
||||||
}
|
private object KeystoreWrappedEd25519KeyProvider : KeyProvider {
|
||||||
val keyPair = KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run {
|
|
||||||
initialize(parameterSpec)
|
override fun getPublic(): PublicKey =
|
||||||
generateKeyPair()
|
runCatching { parseSshPublicKey(sshPublicKey!!)!! }.getOrElse { error ->
|
||||||
|
e(error)
|
||||||
|
throw IOException("Failed to get the public key for wrapped ed25519 key", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPrivate(): PrivateKey =
|
||||||
|
runCatching {
|
||||||
|
// The current MasterKey API does not allow getting a reference to an existing one
|
||||||
|
// without specifying the KeySpec for a new one. However, the value for passed here
|
||||||
|
// for `requireAuthentication` is not used as the key already exists at this point.
|
||||||
|
val encryptedPrivateKeyFile = runBlocking { getOrCreateWrappedPrivateKeyFile(false) }
|
||||||
|
val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() }
|
||||||
|
EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC))
|
||||||
|
}
|
||||||
|
.getOrElse { error ->
|
||||||
|
e(error)
|
||||||
|
throw IOException("Failed to unwrap wrapped ed25519 key", error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write public key in SSH format to .ssh_key.pub.
|
override fun getType(): KeyType = KeyType.fromKey(public)
|
||||||
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
|
}
|
||||||
|
|
||||||
type = Type.KeystoreNative
|
|
||||||
}
|
|
||||||
|
|
||||||
fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = when (type) {
|
|
||||||
Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder)
|
|
||||||
Type.KeystoreNative -> KeystoreNativeKeyProvider
|
|
||||||
Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider
|
|
||||||
null -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
private object KeystoreNativeKeyProvider : KeyProvider {
|
|
||||||
|
|
||||||
override fun getPublic(): PublicKey = runCatching {
|
|
||||||
androidKeystore.sshPublicKey!!
|
|
||||||
}.getOrElse { error ->
|
|
||||||
e(error)
|
|
||||||
throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPrivate(): PrivateKey = runCatching {
|
|
||||||
androidKeystore.sshPrivateKey!!
|
|
||||||
}.getOrElse { error ->
|
|
||||||
e(error)
|
|
||||||
throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getType(): KeyType = KeyType.fromKey(public)
|
|
||||||
}
|
|
||||||
|
|
||||||
private object KeystoreWrappedEd25519KeyProvider : KeyProvider {
|
|
||||||
|
|
||||||
override fun getPublic(): PublicKey = runCatching {
|
|
||||||
parseSshPublicKey(sshPublicKey!!)!!
|
|
||||||
}.getOrElse { error ->
|
|
||||||
e(error)
|
|
||||||
throw IOException("Failed to get the public key for wrapped ed25519 key", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPrivate(): PrivateKey = runCatching {
|
|
||||||
// The current MasterKey API does not allow getting a reference to an existing one
|
|
||||||
// without specifying the KeySpec for a new one. However, the value for passed here
|
|
||||||
// for `requireAuthentication` is not used as the key already exists at this point.
|
|
||||||
val encryptedPrivateKeyFile = runBlocking {
|
|
||||||
getOrCreateWrappedPrivateKeyFile(false)
|
|
||||||
}
|
|
||||||
val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() }
|
|
||||||
EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC))
|
|
||||||
}.getOrElse { error ->
|
|
||||||
e(error)
|
|
||||||
throw IOException("Failed to unwrap wrapped ed25519 key", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getType(): KeyType = KeyType.fromKey(public)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,250 +33,240 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||||
import org.slf4j.Logger
|
import org.slf4j.Logger
|
||||||
import org.slf4j.Marker
|
import org.slf4j.Marker
|
||||||
|
|
||||||
|
|
||||||
fun setUpBouncyCastleForSshj() {
|
fun setUpBouncyCastleForSshj() {
|
||||||
// Replace the Android BC provider with the Java BouncyCastle provider since the former does
|
// Replace the Android BC provider with the Java BouncyCastle provider since the former does
|
||||||
// not include all the required algorithms.
|
// not include all the required algorithms.
|
||||||
// Note: This may affect crypto operations in other parts of the application.
|
// Note: This may affect crypto operations in other parts of the application.
|
||||||
val bcIndex = Security.getProviders().indexOfFirst {
|
val bcIndex = Security.getProviders().indexOfFirst { it.name == BouncyCastleProvider.PROVIDER_NAME }
|
||||||
it.name == BouncyCastleProvider.PROVIDER_NAME
|
if (bcIndex == -1) {
|
||||||
}
|
// No Android BC found, install Java BC at lowest priority.
|
||||||
if (bcIndex == -1) {
|
Security.addProvider(BouncyCastleProvider())
|
||||||
// No Android BC found, install Java BC at lowest priority.
|
} else {
|
||||||
Security.addProvider(BouncyCastleProvider())
|
// Replace Android BC with Java BC, inserted at the same position.
|
||||||
} else {
|
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
|
||||||
// Replace Android BC with Java BC, inserted at the same position.
|
// May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261
|
||||||
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
|
runCatching { Class.forName("sun.security.jca.Providers") }
|
||||||
// May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261
|
Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
|
||||||
runCatching { Class.forName("sun.security.jca.Providers") }
|
}
|
||||||
Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
|
d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" }
|
||||||
}
|
// Prevent sshj from forwarding all cryptographic operations to BC.
|
||||||
d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" }
|
SecurityUtils.setRegisterBouncyCastle(false)
|
||||||
// Prevent sshj from forwarding all cryptographic operations to BC.
|
SecurityUtils.setSecurityProvider(null)
|
||||||
SecurityUtils.setRegisterBouncyCastle(false)
|
|
||||||
SecurityUtils.setSecurityProvider(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private abstract class AbstractLogger(private val name: String) : Logger {
|
private abstract class AbstractLogger(private val name: String) : Logger {
|
||||||
|
|
||||||
abstract fun t(message: String, t: Throwable? = null, vararg args: Any?)
|
abstract fun t(message: String, t: Throwable? = null, vararg args: Any?)
|
||||||
abstract fun d(message: String, t: Throwable? = null, vararg args: Any?)
|
abstract fun d(message: String, t: Throwable? = null, vararg args: Any?)
|
||||||
abstract fun i(message: String, t: Throwable? = null, vararg args: Any?)
|
abstract fun i(message: String, t: Throwable? = null, vararg args: Any?)
|
||||||
abstract fun w(message: String, t: Throwable? = null, vararg args: Any?)
|
abstract fun w(message: String, t: Throwable? = null, vararg args: Any?)
|
||||||
abstract fun e(message: String, t: Throwable? = null, vararg args: Any?)
|
abstract fun e(message: String, t: Throwable? = null, vararg args: Any?)
|
||||||
|
|
||||||
override fun getName() = name
|
override fun getName() = name
|
||||||
|
|
||||||
override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled
|
override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled
|
||||||
override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled
|
override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled
|
||||||
override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled
|
override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled
|
||||||
override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled
|
override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled
|
||||||
override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled
|
override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled
|
||||||
|
|
||||||
override fun trace(msg: String) = t(msg)
|
override fun trace(msg: String) = t(msg)
|
||||||
override fun trace(format: String, arg: Any?) = t(format, null, arg)
|
override fun trace(format: String, arg: Any?) = t(format, null, arg)
|
||||||
override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2)
|
override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2)
|
||||||
override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments)
|
override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments)
|
||||||
override fun trace(msg: String, t: Throwable?) = t(msg, t)
|
override fun trace(msg: String, t: Throwable?) = t(msg, t)
|
||||||
override fun trace(marker: Marker, msg: String) = trace(msg)
|
override fun trace(marker: Marker, msg: String) = trace(msg)
|
||||||
override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg)
|
override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg)
|
||||||
override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = trace(format, arg1, arg2)
|
||||||
trace(format, arg1, arg2)
|
|
||||||
|
|
||||||
override fun trace(marker: Marker?, format: String, vararg arguments: Any?) =
|
override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = trace(format, *arguments)
|
||||||
trace(format, *arguments)
|
|
||||||
|
|
||||||
override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t)
|
override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t)
|
||||||
|
|
||||||
override fun debug(msg: String) = d(msg)
|
override fun debug(msg: String) = d(msg)
|
||||||
override fun debug(format: String, arg: Any?) = d(format, null, arg)
|
override fun debug(format: String, arg: Any?) = d(format, null, arg)
|
||||||
override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2)
|
override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2)
|
||||||
override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments)
|
override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments)
|
||||||
override fun debug(msg: String, t: Throwable?) = d(msg, t)
|
override fun debug(msg: String, t: Throwable?) = d(msg, t)
|
||||||
override fun debug(marker: Marker, msg: String) = debug(msg)
|
override fun debug(marker: Marker, msg: String) = debug(msg)
|
||||||
override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg)
|
override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg)
|
||||||
override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = debug(format, arg1, arg2)
|
||||||
debug(format, arg1, arg2)
|
|
||||||
|
|
||||||
override fun debug(marker: Marker?, format: String, vararg arguments: Any?) =
|
override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = debug(format, *arguments)
|
||||||
debug(format, *arguments)
|
|
||||||
|
|
||||||
override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t)
|
override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t)
|
||||||
|
|
||||||
override fun info(msg: String) = i(msg)
|
override fun info(msg: String) = i(msg)
|
||||||
override fun info(format: String, arg: Any?) = i(format, null, arg)
|
override fun info(format: String, arg: Any?) = i(format, null, arg)
|
||||||
override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2)
|
override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2)
|
||||||
override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments)
|
override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments)
|
||||||
override fun info(msg: String, t: Throwable?) = i(msg, t)
|
override fun info(msg: String, t: Throwable?) = i(msg, t)
|
||||||
override fun info(marker: Marker, msg: String) = info(msg)
|
override fun info(marker: Marker, msg: String) = info(msg)
|
||||||
override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg)
|
override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg)
|
||||||
override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = info(format, arg1, arg2)
|
||||||
info(format, arg1, arg2)
|
|
||||||
|
|
||||||
override fun info(marker: Marker?, format: String, vararg arguments: Any?) =
|
override fun info(marker: Marker?, format: String, vararg arguments: Any?) = info(format, *arguments)
|
||||||
info(format, *arguments)
|
|
||||||
|
|
||||||
override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t)
|
override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t)
|
||||||
|
|
||||||
override fun warn(msg: String) = w(msg)
|
override fun warn(msg: String) = w(msg)
|
||||||
override fun warn(format: String, arg: Any?) = w(format, null, arg)
|
override fun warn(format: String, arg: Any?) = w(format, null, arg)
|
||||||
override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2)
|
override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2)
|
||||||
override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments)
|
override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments)
|
||||||
override fun warn(msg: String, t: Throwable?) = w(msg, t)
|
override fun warn(msg: String, t: Throwable?) = w(msg, t)
|
||||||
override fun warn(marker: Marker, msg: String) = warn(msg)
|
override fun warn(marker: Marker, msg: String) = warn(msg)
|
||||||
override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg)
|
override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg)
|
||||||
override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = warn(format, arg1, arg2)
|
||||||
warn(format, arg1, arg2)
|
|
||||||
|
|
||||||
override fun warn(marker: Marker?, format: String, vararg arguments: Any?) =
|
override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = warn(format, *arguments)
|
||||||
warn(format, *arguments)
|
|
||||||
|
|
||||||
override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t)
|
override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t)
|
||||||
|
|
||||||
override fun error(msg: String) = e(msg)
|
override fun error(msg: String) = e(msg)
|
||||||
override fun error(format: String, arg: Any?) = e(format, null, arg)
|
override fun error(format: String, arg: Any?) = e(format, null, arg)
|
||||||
override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2)
|
override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2)
|
||||||
override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments)
|
override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments)
|
||||||
override fun error(msg: String, t: Throwable?) = e(msg, t)
|
override fun error(msg: String, t: Throwable?) = e(msg, t)
|
||||||
override fun error(marker: Marker, msg: String) = error(msg)
|
override fun error(marker: Marker, msg: String) = error(msg)
|
||||||
override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg)
|
override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg)
|
||||||
override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = error(format, arg1, arg2)
|
||||||
error(format, arg1, arg2)
|
|
||||||
|
|
||||||
override fun error(marker: Marker?, format: String, vararg arguments: Any?) =
|
override fun error(marker: Marker?, format: String, vararg arguments: Any?) = error(format, *arguments)
|
||||||
error(format, *arguments)
|
|
||||||
|
|
||||||
override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t)
|
override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
object TimberLoggerFactory : LoggerFactory {
|
object TimberLoggerFactory : LoggerFactory {
|
||||||
private class TimberLogger(name: String) : AbstractLogger(name) {
|
private class TimberLogger(name: String) : AbstractLogger(name) {
|
||||||
|
|
||||||
// We defer the log level checks to Timber.
|
// We defer the log level checks to Timber.
|
||||||
override fun isTraceEnabled() = true
|
override fun isTraceEnabled() = true
|
||||||
override fun isDebugEnabled() = true
|
override fun isDebugEnabled() = true
|
||||||
override fun isInfoEnabled() = true
|
override fun isInfoEnabled() = true
|
||||||
override fun isWarnEnabled() = true
|
override fun isWarnEnabled() = true
|
||||||
override fun isErrorEnabled() = true
|
override fun isErrorEnabled() = true
|
||||||
|
|
||||||
// Replace slf4j's "{}" format string style with standard Java's "%s".
|
// Replace slf4j's "{}" format string style with standard Java's "%s".
|
||||||
// The supposedly redundant escape on the } is not redundant.
|
// The supposedly redundant escape on the } is not redundant.
|
||||||
@Suppress("RegExpRedundantEscape")
|
@Suppress("RegExpRedundantEscape") private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
|
||||||
private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
|
|
||||||
|
|
||||||
override fun t(message: String, t: Throwable?, vararg args: Any?) {
|
override fun t(message: String, t: Throwable?, vararg args: Any?) {
|
||||||
Timber.tag(name).v(t, message.fix(), *args)
|
Timber.tag(name).v(t, message.fix(), *args)
|
||||||
}
|
|
||||||
|
|
||||||
override fun d(message: String, t: Throwable?, vararg args: Any?) {
|
|
||||||
Timber.tag(name).d(t, message.fix(), *args)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun i(message: String, t: Throwable?, vararg args: Any?) {
|
|
||||||
Timber.tag(name).i(t, message.fix(), *args)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun w(message: String, t: Throwable?, vararg args: Any?) {
|
|
||||||
Timber.tag(name).w(t, message.fix(), *args)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun e(message: String, t: Throwable?, vararg args: Any?) {
|
|
||||||
Timber.tag(name).e(t, message.fix(), *args)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLogger(name: String): Logger {
|
override fun d(message: String, t: Throwable?, vararg args: Any?) {
|
||||||
return TimberLogger(name)
|
Timber.tag(name).d(t, message.fix(), *args)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLogger(clazz: Class<*>): Logger {
|
override fun i(message: String, t: Throwable?, vararg args: Any?) {
|
||||||
return TimberLogger(clazz.name)
|
Timber.tag(name).i(t, message.fix(), *args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun w(message: String, t: Throwable?, vararg args: Any?) {
|
||||||
|
Timber.tag(name).w(t, message.fix(), *args)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun e(message: String, t: Throwable?, vararg args: Any?) {
|
||||||
|
Timber.tag(name).e(t, message.fix(), *args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLogger(name: String): Logger {
|
||||||
|
return TimberLogger(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLogger(clazz: Class<*>): Logger {
|
||||||
|
return TimberLogger(clazz.name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SshjConfig : ConfigImpl() {
|
class SshjConfig : ConfigImpl() {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loggerFactory = TimberLoggerFactory
|
loggerFactory = TimberLoggerFactory
|
||||||
keepAliveProvider = KeepAliveProvider.HEARTBEAT
|
keepAliveProvider = KeepAliveProvider.HEARTBEAT
|
||||||
version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1"
|
version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1"
|
||||||
|
|
||||||
initKeyExchangeFactories()
|
initKeyExchangeFactories()
|
||||||
initKeyAlgorithms()
|
initKeyAlgorithms()
|
||||||
initRandomFactory()
|
initRandomFactory()
|
||||||
initFileKeyProviderFactories()
|
initFileKeyProviderFactories()
|
||||||
initCipherFactories()
|
initCipherFactories()
|
||||||
initCompressionFactories()
|
initCompressionFactories()
|
||||||
initMACFactories()
|
initMACFactories()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initKeyExchangeFactories() {
|
private fun initKeyExchangeFactories() {
|
||||||
keyExchangeFactories = listOf(
|
keyExchangeFactories =
|
||||||
Curve25519SHA256.Factory(),
|
listOf(
|
||||||
FactoryLibSsh(),
|
Curve25519SHA256.Factory(),
|
||||||
ECDHNistP.Factory521(),
|
FactoryLibSsh(),
|
||||||
ECDHNistP.Factory384(),
|
ECDHNistP.Factory521(),
|
||||||
ECDHNistP.Factory256(),
|
ECDHNistP.Factory384(),
|
||||||
DHGexSHA256.Factory(),
|
ECDHNistP.Factory256(),
|
||||||
// Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get
|
DHGexSHA256.Factory(),
|
||||||
// rsa-sha2-* key types to work with some servers (e.g. GitHub).
|
// Sends "ext-info-c" with the list of key exchange algorithms. This is needed to
|
||||||
ExtInfoClientFactory(),
|
// get
|
||||||
)
|
// rsa-sha2-* key types to work with some servers (e.g. GitHub).
|
||||||
}
|
ExtInfoClientFactory(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun initKeyAlgorithms() {
|
private fun initKeyAlgorithms() {
|
||||||
keyAlgorithms = listOf(
|
keyAlgorithms =
|
||||||
KeyAlgorithms.SSHRSACertV01(),
|
listOf(
|
||||||
KeyAlgorithms.EdDSA25519(),
|
KeyAlgorithms.SSHRSACertV01(),
|
||||||
KeyAlgorithms.ECDSASHANistp521(),
|
KeyAlgorithms.EdDSA25519(),
|
||||||
KeyAlgorithms.ECDSASHANistp384(),
|
KeyAlgorithms.ECDSASHANistp521(),
|
||||||
KeyAlgorithms.ECDSASHANistp256(),
|
KeyAlgorithms.ECDSASHANistp384(),
|
||||||
KeyAlgorithms.RSASHA512(),
|
KeyAlgorithms.ECDSASHANistp256(),
|
||||||
KeyAlgorithms.RSASHA256(),
|
KeyAlgorithms.RSASHA512(),
|
||||||
KeyAlgorithms.SSHRSA(),
|
KeyAlgorithms.RSASHA256(),
|
||||||
).map {
|
KeyAlgorithms.SSHRSA(),
|
||||||
OpenKeychainWrappedKeyAlgorithmFactory(it)
|
)
|
||||||
}
|
.map { OpenKeychainWrappedKeyAlgorithmFactory(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initRandomFactory() {
|
private fun initRandomFactory() {
|
||||||
randomFactory = SingletonRandomFactory(JCERandom.Factory())
|
randomFactory = SingletonRandomFactory(JCERandom.Factory())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initFileKeyProviderFactories() {
|
private fun initFileKeyProviderFactories() {
|
||||||
fileKeyProviderFactories = listOf(
|
fileKeyProviderFactories =
|
||||||
OpenSSHKeyV1KeyFile.Factory(),
|
listOf(
|
||||||
PKCS8KeyFile.Factory(),
|
OpenSSHKeyV1KeyFile.Factory(),
|
||||||
PKCS5KeyFile.Factory(),
|
PKCS8KeyFile.Factory(),
|
||||||
OpenSSHKeyFile.Factory(),
|
PKCS5KeyFile.Factory(),
|
||||||
PuTTYKeyFile.Factory(),
|
OpenSSHKeyFile.Factory(),
|
||||||
)
|
PuTTYKeyFile.Factory(),
|
||||||
}
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initCipherFactories() {
|
||||||
|
cipherFactories =
|
||||||
|
listOf(
|
||||||
|
GcmCiphers.AES128GCM(),
|
||||||
|
GcmCiphers.AES256GCM(),
|
||||||
|
BlockCiphers.AES256CTR(),
|
||||||
|
BlockCiphers.AES192CTR(),
|
||||||
|
BlockCiphers.AES128CTR(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun initCipherFactories() {
|
private fun initMACFactories() {
|
||||||
cipherFactories = listOf(
|
macFactories =
|
||||||
GcmCiphers.AES128GCM(),
|
listOf(
|
||||||
GcmCiphers.AES256GCM(),
|
Macs.HMACSHA2512Etm(),
|
||||||
BlockCiphers.AES256CTR(),
|
Macs.HMACSHA2256Etm(),
|
||||||
BlockCiphers.AES192CTR(),
|
Macs.HMACSHA2512(),
|
||||||
BlockCiphers.AES128CTR(),
|
Macs.HMACSHA2256(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initMACFactories() {
|
private fun initCompressionFactories() {
|
||||||
macFactories = listOf(
|
compressionFactories =
|
||||||
Macs.HMACSHA2512Etm(),
|
listOf(
|
||||||
Macs.HMACSHA2256Etm(),
|
NoneCompression.Factory(),
|
||||||
Macs.HMACSHA2512(),
|
)
|
||||||
Macs.HMACSHA2256(),
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initCompressionFactories() {
|
|
||||||
compressionFactories = listOf(
|
|
||||||
NoneCompression.Factory(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,158 +40,155 @@ import org.eclipse.jgit.transport.URIish
|
||||||
import org.eclipse.jgit.util.FS
|
import org.eclipse.jgit.util.FS
|
||||||
|
|
||||||
sealed class SshAuthMethod(val activity: ContinuationContainerActivity) {
|
sealed class SshAuthMethod(val activity: ContinuationContainerActivity) {
|
||||||
class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||||
class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||||
class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract class InteractivePasswordFinder : PasswordFinder {
|
abstract class InteractivePasswordFinder : PasswordFinder {
|
||||||
|
|
||||||
private var isRetry = false
|
private var isRetry = false
|
||||||
|
|
||||||
abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean)
|
abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean)
|
||||||
|
|
||||||
final override fun reqPassword(resource: Resource<*>?): CharArray {
|
final override fun reqPassword(resource: Resource<*>?): CharArray {
|
||||||
val password = runBlocking(Dispatchers.Main) {
|
val password = runBlocking(Dispatchers.Main) { suspendCoroutine<String?> { cont -> askForPassword(cont, isRetry) } }
|
||||||
suspendCoroutine<String?> { cont ->
|
isRetry = true
|
||||||
askForPassword(cont, isRetry)
|
return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
isRetry = true
|
|
||||||
return password?.toCharArray()
|
|
||||||
?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
|
|
||||||
}
|
|
||||||
|
|
||||||
final override fun shouldRetry(resource: Resource<*>?) = true
|
final override fun shouldRetry(resource: Resource<*>?) = true
|
||||||
}
|
}
|
||||||
|
|
||||||
class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : SshSessionFactory() {
|
class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : SshSessionFactory() {
|
||||||
|
|
||||||
private var currentSession: SshjSession? = null
|
private var currentSession: SshjSession? = null
|
||||||
|
|
||||||
override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession {
|
override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession {
|
||||||
return currentSession
|
return currentSession
|
||||||
?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
|
?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
|
||||||
d { "New SSH connection created" }
|
d { "New SSH connection created" }
|
||||||
currentSession = it
|
currentSession = it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
currentSession?.close()
|
currentSession?.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
|
private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
|
||||||
if (!hostKeyFile.exists()) {
|
if (!hostKeyFile.exists()) {
|
||||||
return HostKeyVerifier { _, _, key ->
|
return HostKeyVerifier { _, _, key ->
|
||||||
val digest = runCatching {
|
val digest =
|
||||||
SecurityUtils.getMessageDigest("SHA-256")
|
runCatching { SecurityUtils.getMessageDigest("SHA-256") }.getOrElse { e -> throw SSHRuntimeException(e) }
|
||||||
}.getOrElse { e ->
|
digest.update(PlainBuffer().putPublicKey(key).compactData)
|
||||||
throw SSHRuntimeException(e)
|
val digestData = digest.digest()
|
||||||
}
|
val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}"
|
||||||
digest.update(PlainBuffer().putPublicKey(key).compactData)
|
d { "Trusting host key on first use: $hostKeyEntry" }
|
||||||
val digestData = digest.digest()
|
hostKeyFile.writeText(hostKeyEntry)
|
||||||
val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}"
|
true
|
||||||
d { "Trusting host key on first use: $hostKeyEntry" }
|
|
||||||
hostKeyFile.writeText(hostKeyEntry)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val hostKeyEntry = hostKeyFile.readText()
|
|
||||||
d { "Pinned host key: $hostKeyEntry" }
|
|
||||||
return FingerprintVerifier.getInstance(hostKeyEntry)
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
val hostKeyEntry = hostKeyFile.readText()
|
||||||
|
d { "Pinned host key: $hostKeyEntry" }
|
||||||
|
return FingerprintVerifier.getInstance(hostKeyEntry)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class SshjSession(uri: URIish, private val username: String, private val authMethod: SshAuthMethod, private val hostKeyFile: File) : RemoteSession {
|
private class SshjSession(
|
||||||
|
uri: URIish,
|
||||||
|
private val username: String,
|
||||||
|
private val authMethod: SshAuthMethod,
|
||||||
|
private val hostKeyFile: File
|
||||||
|
) : RemoteSession {
|
||||||
|
|
||||||
private lateinit var ssh: SSHClient
|
private lateinit var ssh: SSHClient
|
||||||
private var currentCommand: Session? = null
|
private var currentCommand: Session? = null
|
||||||
|
|
||||||
private val uri = if (uri.host.contains('@')) {
|
private val uri =
|
||||||
// URIish's String constructor cannot handle '@' in the user part of the URI and the URL
|
if (uri.host.contains('@')) {
|
||||||
// constructor can't be used since Java's URL does not recognize the ssh scheme. We thus
|
// URIish's String constructor cannot handle '@' in the user part of the URI and the URL
|
||||||
// need to patch everything up ourselves.
|
// constructor can't be used since Java's URL does not recognize the ssh scheme. We thus
|
||||||
d { "Before fixup: user=${uri.user}, host=${uri.host}" }
|
// need to patch everything up ourselves.
|
||||||
val userPlusHost = "${uri.user}@${uri.host}"
|
d { "Before fixup: user=${uri.user}, host=${uri.host}" }
|
||||||
val realUser = userPlusHost.substringBeforeLast('@')
|
val userPlusHost = "${uri.user}@${uri.host}"
|
||||||
val realHost = userPlusHost.substringAfterLast('@')
|
val realUser = userPlusHost.substringBeforeLast('@')
|
||||||
uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } }
|
val realHost = userPlusHost.substringAfterLast('@')
|
||||||
|
uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } }
|
||||||
} else {
|
} else {
|
||||||
uri
|
uri
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect(): SshjSession {
|
fun connect(): SshjSession {
|
||||||
ssh = SSHClient(SshjConfig())
|
ssh = SSHClient(SshjConfig())
|
||||||
ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile))
|
ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile))
|
||||||
ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
|
ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
|
||||||
if (!ssh.isConnected)
|
if (!ssh.isConnected) throw IOException()
|
||||||
throw IOException()
|
val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password))
|
||||||
val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password))
|
when (authMethod) {
|
||||||
when (authMethod) {
|
is SshAuthMethod.Password -> {
|
||||||
is SshAuthMethod.Password -> {
|
ssh.auth(username, passwordAuth)
|
||||||
ssh.auth(username, passwordAuth)
|
}
|
||||||
}
|
is SshAuthMethod.SshKey -> {
|
||||||
is SshAuthMethod.SshKey -> {
|
val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
|
||||||
val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
|
ssh.auth(username, pubkeyAuth, passwordAuth)
|
||||||
ssh.auth(username, pubkeyAuth, passwordAuth)
|
}
|
||||||
}
|
is SshAuthMethod.OpenKeychain -> {
|
||||||
is SshAuthMethod.OpenKeychain -> {
|
runBlocking {
|
||||||
runBlocking {
|
OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider ->
|
||||||
OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider ->
|
val openKeychainAuth = AuthPublickey(provider)
|
||||||
val openKeychainAuth = AuthPublickey(provider)
|
ssh.auth(username, openKeychainAuth, passwordAuth)
|
||||||
ssh.auth(username, openKeychainAuth, passwordAuth)
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return this
|
}
|
||||||
}
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
override fun exec(commandName: String?, timeout: Int): Process {
|
override fun exec(commandName: String?, timeout: Int): Process {
|
||||||
if (currentCommand != null) {
|
if (currentCommand != null) {
|
||||||
w { "Killing old command" }
|
w { "Killing old command" }
|
||||||
disconnect()
|
disconnect()
|
||||||
}
|
|
||||||
val session = ssh.startSession()
|
|
||||||
currentCommand = session
|
|
||||||
return SshjProcess(session.exec(commandName), timeout.toLong())
|
|
||||||
}
|
}
|
||||||
|
val session = ssh.startSession()
|
||||||
|
currentCommand = session
|
||||||
|
return SshjProcess(session.exec(commandName), timeout.toLong())
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kills the current command if one is running and returns the session into a state where `exec`
|
* Kills the current command if one is running and returns the session into a state where `exec`
|
||||||
* can be called.
|
* can be called.
|
||||||
*
|
*
|
||||||
* Note that this does *not* disconnect the session. Unfortunately, the function has to be
|
* Note that this does *not* disconnect the session. Unfortunately, the function has to be called
|
||||||
* called `disconnect` to override the corresponding abstract function in `RemoteSession`.
|
* `disconnect` to override the corresponding abstract function in `RemoteSession`.
|
||||||
*/
|
*/
|
||||||
override fun disconnect() {
|
override fun disconnect() {
|
||||||
currentCommand?.close()
|
currentCommand?.close()
|
||||||
currentCommand = null
|
currentCommand = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun close() {
|
fun close() {
|
||||||
disconnect()
|
disconnect()
|
||||||
ssh.close()
|
ssh.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() {
|
private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() {
|
||||||
|
|
||||||
override fun waitFor(): Int {
|
override fun waitFor(): Int {
|
||||||
command.join(timeout, TimeUnit.SECONDS)
|
command.join(timeout, TimeUnit.SECONDS)
|
||||||
command.close()
|
command.close()
|
||||||
return exitValue()
|
return exitValue()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun destroy() = command.close()
|
override fun destroy() = command.close()
|
||||||
|
|
||||||
override fun getOutputStream(): OutputStream = command.outputStream
|
override fun getOutputStream(): OutputStream = command.outputStream
|
||||||
|
|
||||||
override fun getErrorStream(): InputStream = command.errorStream
|
override fun getErrorStream(): InputStream = command.errorStream
|
||||||
|
|
||||||
override fun exitValue(): Int = command.exitStatus
|
override fun exitValue(): Int = command.exitStatus
|
||||||
|
|
||||||
override fun getInputStream(): InputStream = command.inputStream
|
override fun getInputStream(): InputStream = command.inputStream
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,52 +15,52 @@ import java.net.ProxySelector
|
||||||
import java.net.SocketAddress
|
import java.net.SocketAddress
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
/**
|
/** Utility class for [Proxy] handling. */
|
||||||
* Utility class for [Proxy] handling.
|
|
||||||
*/
|
|
||||||
object ProxyUtils {
|
object ProxyUtils {
|
||||||
|
|
||||||
private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser"
|
private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser"
|
||||||
private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword"
|
private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword"
|
||||||
|
|
||||||
/**
|
/** Set the default [Proxy] and [Authenticator] for the app based on user provided settings. */
|
||||||
* Set the default [Proxy] and [Authenticator] for the app based on user provided settings.
|
fun setDefaultProxy() {
|
||||||
*/
|
ProxySelector.setDefault(
|
||||||
fun setDefaultProxy() {
|
object : ProxySelector() {
|
||||||
ProxySelector.setDefault(object : ProxySelector() {
|
override fun select(uri: URI?): MutableList<Proxy> {
|
||||||
override fun select(uri: URI?): MutableList<Proxy> {
|
val host = GitSettings.proxyHost
|
||||||
val host = GitSettings.proxyHost
|
val port = GitSettings.proxyPort
|
||||||
val port = GitSettings.proxyPort
|
return if (host == null || port == -1) {
|
||||||
return if (host == null || port == -1) {
|
mutableListOf(Proxy.NO_PROXY)
|
||||||
mutableListOf(Proxy.NO_PROXY)
|
} else {
|
||||||
} else {
|
mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port)))
|
||||||
mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port)))
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
|
|
||||||
if (uri == null || sa == null || ioe == null) {
|
|
||||||
throw IllegalArgumentException("Arguments can't be null.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
val user = GitSettings.proxyUsername ?: ""
|
|
||||||
val password = GitSettings.proxyPassword ?: ""
|
|
||||||
if (user.isEmpty() || password.isEmpty()) {
|
|
||||||
System.clearProperty(HTTP_PROXY_USER_PROPERTY)
|
|
||||||
System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
|
|
||||||
} else {
|
|
||||||
System.setProperty(HTTP_PROXY_USER_PROPERTY, user)
|
|
||||||
System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password)
|
|
||||||
}
|
}
|
||||||
Authenticator.setDefault(object : Authenticator() {
|
|
||||||
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
|
||||||
return if (requestorType == RequestorType.PROXY) {
|
if (uri == null || sa == null || ioe == null) {
|
||||||
PasswordAuthentication(user, password.toCharArray())
|
throw IllegalArgumentException("Arguments can't be null.")
|
||||||
} else {
|
}
|
||||||
null
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
})
|
val user = GitSettings.proxyUsername ?: ""
|
||||||
|
val password = GitSettings.proxyPassword ?: ""
|
||||||
|
if (user.isEmpty() || password.isEmpty()) {
|
||||||
|
System.clearProperty(HTTP_PROXY_USER_PROPERTY)
|
||||||
|
System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
|
||||||
|
} else {
|
||||||
|
System.setProperty(HTTP_PROXY_USER_PROPERTY, user)
|
||||||
|
System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password)
|
||||||
}
|
}
|
||||||
|
Authenticator.setDefault(
|
||||||
|
object : Authenticator() {
|
||||||
|
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||||
|
return if (requestorType == RequestorType.PROXY) {
|
||||||
|
PasswordAuthentication(user, password.toCharArray())
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,128 +12,118 @@ import dev.msfjarvis.aps.util.extensions.hasFlag
|
||||||
import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
|
|
||||||
enum class PasswordOption(val key: String) {
|
enum class PasswordOption(val key: String) {
|
||||||
NoDigits("0"),
|
NoDigits("0"),
|
||||||
NoUppercaseLetters("A"),
|
NoUppercaseLetters("A"),
|
||||||
NoAmbiguousCharacters("B"),
|
NoAmbiguousCharacters("B"),
|
||||||
FullyRandom("s"),
|
FullyRandom("s"),
|
||||||
AtLeastOneSymbol("y"),
|
AtLeastOneSymbol("y"),
|
||||||
NoLowercaseLetters("L")
|
NoLowercaseLetters("L")
|
||||||
}
|
}
|
||||||
|
|
||||||
object PasswordGenerator {
|
object PasswordGenerator {
|
||||||
|
|
||||||
const val DEFAULT_LENGTH = 16
|
const val DEFAULT_LENGTH = 16
|
||||||
|
|
||||||
const val DIGITS = 0x0001
|
const val DIGITS = 0x0001
|
||||||
const val UPPERS = 0x0002
|
const val UPPERS = 0x0002
|
||||||
const val SYMBOLS = 0x0004
|
const val SYMBOLS = 0x0004
|
||||||
const val NO_AMBIGUOUS = 0x0008
|
const val NO_AMBIGUOUS = 0x0008
|
||||||
const val LOWERS = 0x0020
|
const val LOWERS = 0x0020
|
||||||
|
|
||||||
const val DIGITS_STR = "0123456789"
|
const val DIGITS_STR = "0123456789"
|
||||||
const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz"
|
const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz"
|
||||||
const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
|
const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
|
||||||
const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2"
|
const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for
|
* Enables the [PasswordOption] s in [options] and sets [targetLength] as the length for generated
|
||||||
* generated passwords.
|
* passwords.
|
||||||
*/
|
*/
|
||||||
fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean {
|
fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean {
|
||||||
ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
|
ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
|
||||||
for (possibleOption in PasswordOption.values())
|
for (possibleOption in PasswordOption.values()) putBoolean(possibleOption.key, possibleOption in options)
|
||||||
putBoolean(possibleOption.key, possibleOption in options)
|
putInt("length", targetLength)
|
||||||
putInt("length", targetLength)
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidPassword(password: String, pwFlags: Int): Boolean {
|
||||||
|
if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) return false
|
||||||
|
if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) return false
|
||||||
|
if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) return false
|
||||||
|
if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) return false
|
||||||
|
if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates a password using the preferences set by [setPrefs]. */
|
||||||
|
@Throws(PasswordGeneratorException::class)
|
||||||
|
fun generate(ctx: Context): String {
|
||||||
|
val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
||||||
|
var numCharacterCategories = 0
|
||||||
|
|
||||||
|
var phonemes = true
|
||||||
|
var pwgenFlags = DIGITS or UPPERS or LOWERS
|
||||||
|
|
||||||
|
for (option in PasswordOption.values()) {
|
||||||
|
if (prefs.getBoolean(option.key, false)) {
|
||||||
|
when (option) {
|
||||||
|
PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS)
|
||||||
|
PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS)
|
||||||
|
PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS)
|
||||||
|
PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS
|
||||||
|
PasswordOption.FullyRandom -> phonemes = false
|
||||||
|
PasswordOption.AtLeastOneSymbol -> {
|
||||||
|
numCharacterCategories++
|
||||||
|
pwgenFlags = pwgenFlags or SYMBOLS
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true
|
} else {
|
||||||
|
// The No* options are false, so the respective character category will be included.
|
||||||
|
when (option) {
|
||||||
|
PasswordOption.NoDigits, PasswordOption.NoUppercaseLetters, PasswordOption.NoLowercaseLetters -> {
|
||||||
|
numCharacterCategories++
|
||||||
|
}
|
||||||
|
PasswordOption.NoAmbiguousCharacters,
|
||||||
|
PasswordOption.FullyRandom,
|
||||||
|
// Since AtLeastOneSymbol is not negated, it is counted in the if branch.
|
||||||
|
PasswordOption.AtLeastOneSymbol -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isValidPassword(password: String, pwFlags: Int): Boolean {
|
val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH)
|
||||||
if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR })
|
if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) {
|
||||||
return false
|
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error))
|
||||||
if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR })
|
}
|
||||||
return false
|
if (length < numCharacterCategories) {
|
||||||
if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR })
|
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error))
|
||||||
return false
|
}
|
||||||
if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR })
|
if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) {
|
||||||
return false
|
phonemes = false
|
||||||
if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR })
|
pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS)
|
||||||
return false
|
}
|
||||||
return true
|
// Experiments show that phonemes may require more than 1000 iterations to generate a valid
|
||||||
|
// password if the length is not at least 6.
|
||||||
|
if (length < 6) {
|
||||||
|
phonemes = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
var password: String?
|
||||||
* Generates a password using the preferences set by [setPrefs].
|
var iterations = 0
|
||||||
*/
|
do {
|
||||||
@Throws(PasswordGeneratorException::class)
|
if (iterations++ > 1000)
|
||||||
fun generate(ctx: Context): String {
|
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded))
|
||||||
val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
password =
|
||||||
var numCharacterCategories = 0
|
if (phonemes) {
|
||||||
|
RandomPhonemesGenerator.generate(length, pwgenFlags)
|
||||||
var phonemes = true
|
} else {
|
||||||
var pwgenFlags = DIGITS or UPPERS or LOWERS
|
RandomPasswordGenerator.generate(length, pwgenFlags)
|
||||||
|
|
||||||
for (option in PasswordOption.values()) {
|
|
||||||
if (prefs.getBoolean(option.key, false)) {
|
|
||||||
when (option) {
|
|
||||||
PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS)
|
|
||||||
PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS)
|
|
||||||
PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS)
|
|
||||||
PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS
|
|
||||||
PasswordOption.FullyRandom -> phonemes = false
|
|
||||||
PasswordOption.AtLeastOneSymbol -> {
|
|
||||||
numCharacterCategories++
|
|
||||||
pwgenFlags = pwgenFlags or SYMBOLS
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// The No* options are false, so the respective character category will be included.
|
|
||||||
when (option) {
|
|
||||||
PasswordOption.NoDigits,
|
|
||||||
PasswordOption.NoUppercaseLetters,
|
|
||||||
PasswordOption.NoLowercaseLetters -> {
|
|
||||||
numCharacterCategories++
|
|
||||||
}
|
|
||||||
PasswordOption.NoAmbiguousCharacters,
|
|
||||||
PasswordOption.FullyRandom,
|
|
||||||
// Since AtLeastOneSymbol is not negated, it is counted in the if branch.
|
|
||||||
PasswordOption.AtLeastOneSymbol -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} while (password == null)
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
|
||||||
val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH)
|
class PasswordGeneratorException(string: String) : Exception(string)
|
||||||
if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) {
|
|
||||||
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error))
|
|
||||||
}
|
|
||||||
if (length < numCharacterCategories) {
|
|
||||||
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error))
|
|
||||||
}
|
|
||||||
if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) {
|
|
||||||
phonemes = false
|
|
||||||
pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS)
|
|
||||||
}
|
|
||||||
// Experiments show that phonemes may require more than 1000 iterations to generate a valid
|
|
||||||
// password if the length is not at least 6.
|
|
||||||
if (length < 6) {
|
|
||||||
phonemes = false
|
|
||||||
}
|
|
||||||
|
|
||||||
var password: String?
|
|
||||||
var iterations = 0
|
|
||||||
do {
|
|
||||||
if (iterations++ > 1000)
|
|
||||||
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded))
|
|
||||||
password = if (phonemes) {
|
|
||||||
RandomPhonemesGenerator.generate(length, pwgenFlags)
|
|
||||||
} else {
|
|
||||||
RandomPasswordGenerator.generate(length, pwgenFlags)
|
|
||||||
}
|
|
||||||
} while (password == null)
|
|
||||||
return password
|
|
||||||
}
|
|
||||||
|
|
||||||
class PasswordGeneratorException(string: String) : Exception(string)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,26 +8,24 @@ import java.security.SecureRandom
|
||||||
|
|
||||||
private val secureRandom = SecureRandom()
|
private val secureRandom = SecureRandom()
|
||||||
|
|
||||||
/**
|
/** Returns a number between 0 (inclusive) and [exclusiveBound](exclusive). */
|
||||||
* Returns a number between 0 (inclusive) and [exclusiveBound] (exclusive).
|
|
||||||
*/
|
|
||||||
fun secureRandomNumber(exclusiveBound: Int) = secureRandom.nextInt(exclusiveBound)
|
fun secureRandomNumber(exclusiveBound: Int) = secureRandom.nextInt(exclusiveBound)
|
||||||
|
|
||||||
/**
|
/** Returns `true` and `false` with probablity 50% each. */
|
||||||
* Returns `true` and `false` with probablity 50% each.
|
|
||||||
*/
|
|
||||||
fun secureRandomBoolean() = secureRandom.nextBoolean()
|
fun secureRandomBoolean() = secureRandom.nextBoolean()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns `true` with probability [percentTrue]% and `false` with probability
|
* Returns `true` with probability [percentTrue]% and `false` with probability `(100 - [percentTrue]
|
||||||
* `(100 - [percentTrue])`%.
|
* )`%.
|
||||||
*/
|
*/
|
||||||
fun secureRandomBiasedBoolean(percentTrue: Int): Boolean {
|
fun secureRandomBiasedBoolean(percentTrue: Int): Boolean {
|
||||||
require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" }
|
require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" }
|
||||||
require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" }
|
require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" }
|
||||||
return secureRandomNumber(100) < percentTrue
|
return secureRandomNumber(100) < percentTrue
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T> Array<T>.secureRandomElement() = this[secureRandomNumber(size)]
|
fun <T> Array<T>.secureRandomElement() = this[secureRandomNumber(size)]
|
||||||
|
|
||||||
fun <T> List<T>.secureRandomElement() = this[secureRandomNumber(size)]
|
fun <T> List<T>.secureRandomElement() = this[secureRandomNumber(size)]
|
||||||
|
|
||||||
fun String.secureRandomCharacter() = this[secureRandomNumber(length)]
|
fun String.secureRandomCharacter() = this[secureRandomNumber(length)]
|
||||||
|
|
|
@ -8,38 +8,39 @@ import dev.msfjarvis.aps.util.extensions.hasFlag
|
||||||
|
|
||||||
object RandomPasswordGenerator {
|
object RandomPasswordGenerator {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a random password of length [targetLength], taking the following flags in [pwFlags]
|
* Generates a random password of length [targetLength], taking the following flags in [pwFlags]
|
||||||
* into account, or fails to do so and returns null:
|
* into account, or fails to do so and returns null:
|
||||||
*
|
*
|
||||||
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not
|
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set,
|
||||||
* set, the password will not contain any digits.
|
* the password will not contain any digits.
|
||||||
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase
|
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter;
|
||||||
* letter; if not set, the password will not contain any uppercase letters.
|
* if not set, the password will not contain any uppercase letters.
|
||||||
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase
|
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter;
|
||||||
* letter; if not set, the password will not contain any lowercase letters.
|
* if not set, the password will not contain any lowercase letters.
|
||||||
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
|
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
|
||||||
* set, the password will not contain any symbols.
|
* set, the password will not contain any symbols.
|
||||||
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
|
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
|
||||||
* characters.
|
* characters.
|
||||||
*/
|
*/
|
||||||
fun generate(targetLength: Int, pwFlags: Int): String? {
|
fun generate(targetLength: Int, pwFlags: Int): String? {
|
||||||
val bank = listOfNotNull(
|
val bank =
|
||||||
PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS },
|
listOfNotNull(
|
||||||
PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS },
|
PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS },
|
||||||
PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS },
|
PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS },
|
||||||
PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS },
|
PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS },
|
||||||
).joinToString("")
|
PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS },
|
||||||
|
)
|
||||||
|
.joinToString("")
|
||||||
|
|
||||||
var password = ""
|
var password = ""
|
||||||
while (password.length < targetLength) {
|
while (password.length < targetLength) {
|
||||||
val candidate = bank.secureRandomCharacter()
|
val candidate = bank.secureRandomCharacter()
|
||||||
if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
|
if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate in PasswordGenerator.AMBIGUOUS_STR) {
|
||||||
candidate in PasswordGenerator.AMBIGUOUS_STR) {
|
continue
|
||||||
continue
|
}
|
||||||
}
|
password += candidate
|
||||||
password += candidate
|
|
||||||
}
|
|
||||||
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
|
|
||||||
}
|
}
|
||||||
|
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,161 +9,161 @@ import java.util.Locale
|
||||||
|
|
||||||
object RandomPhonemesGenerator {
|
object RandomPhonemesGenerator {
|
||||||
|
|
||||||
private const val CONSONANT = 0x0001
|
private const val CONSONANT = 0x0001
|
||||||
private const val VOWEL = 0x0002
|
private const val VOWEL = 0x0002
|
||||||
private const val DIPHTHONG = 0x0004
|
private const val DIPHTHONG = 0x0004
|
||||||
private const val NOT_FIRST = 0x0008
|
private const val NOT_FIRST = 0x0008
|
||||||
|
|
||||||
private val elements = arrayOf(
|
private val elements =
|
||||||
Element("a", VOWEL),
|
arrayOf(
|
||||||
Element("ae", VOWEL or DIPHTHONG),
|
Element("a", VOWEL),
|
||||||
Element("ah", VOWEL or DIPHTHONG),
|
Element("ae", VOWEL or DIPHTHONG),
|
||||||
Element("ai", VOWEL or DIPHTHONG),
|
Element("ah", VOWEL or DIPHTHONG),
|
||||||
Element("b", CONSONANT),
|
Element("ai", VOWEL or DIPHTHONG),
|
||||||
Element("c", CONSONANT),
|
Element("b", CONSONANT),
|
||||||
Element("ch", CONSONANT or DIPHTHONG),
|
Element("c", CONSONANT),
|
||||||
Element("d", CONSONANT),
|
Element("ch", CONSONANT or DIPHTHONG),
|
||||||
Element("e", VOWEL),
|
Element("d", CONSONANT),
|
||||||
Element("ee", VOWEL or DIPHTHONG),
|
Element("e", VOWEL),
|
||||||
Element("ei", VOWEL or DIPHTHONG),
|
Element("ee", VOWEL or DIPHTHONG),
|
||||||
Element("f", CONSONANT),
|
Element("ei", VOWEL or DIPHTHONG),
|
||||||
Element("g", CONSONANT),
|
Element("f", CONSONANT),
|
||||||
Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST),
|
Element("g", CONSONANT),
|
||||||
Element("h", CONSONANT),
|
Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST),
|
||||||
Element("i", VOWEL),
|
Element("h", CONSONANT),
|
||||||
Element("ie", VOWEL or DIPHTHONG),
|
Element("i", VOWEL),
|
||||||
Element("j", CONSONANT),
|
Element("ie", VOWEL or DIPHTHONG),
|
||||||
Element("k", CONSONANT),
|
Element("j", CONSONANT),
|
||||||
Element("l", CONSONANT),
|
Element("k", CONSONANT),
|
||||||
Element("m", CONSONANT),
|
Element("l", CONSONANT),
|
||||||
Element("n", CONSONANT),
|
Element("m", CONSONANT),
|
||||||
Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST),
|
Element("n", CONSONANT),
|
||||||
Element("o", VOWEL),
|
Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST),
|
||||||
Element("oh", VOWEL or DIPHTHONG),
|
Element("o", VOWEL),
|
||||||
Element("oo", VOWEL or DIPHTHONG),
|
Element("oh", VOWEL or DIPHTHONG),
|
||||||
Element("p", CONSONANT),
|
Element("oo", VOWEL or DIPHTHONG),
|
||||||
Element("ph", CONSONANT or DIPHTHONG),
|
Element("p", CONSONANT),
|
||||||
Element("qu", CONSONANT or DIPHTHONG),
|
Element("ph", CONSONANT or DIPHTHONG),
|
||||||
Element("r", CONSONANT),
|
Element("qu", CONSONANT or DIPHTHONG),
|
||||||
Element("s", CONSONANT),
|
Element("r", CONSONANT),
|
||||||
Element("sh", CONSONANT or DIPHTHONG),
|
Element("s", CONSONANT),
|
||||||
Element("t", CONSONANT),
|
Element("sh", CONSONANT or DIPHTHONG),
|
||||||
Element("th", CONSONANT or DIPHTHONG),
|
Element("t", CONSONANT),
|
||||||
Element("u", VOWEL),
|
Element("th", CONSONANT or DIPHTHONG),
|
||||||
Element("v", CONSONANT),
|
Element("u", VOWEL),
|
||||||
Element("w", CONSONANT),
|
Element("v", CONSONANT),
|
||||||
Element("x", CONSONANT),
|
Element("w", CONSONANT),
|
||||||
Element("y", CONSONANT),
|
Element("x", CONSONANT),
|
||||||
Element("z", CONSONANT)
|
Element("y", CONSONANT),
|
||||||
|
Element("z", CONSONANT)
|
||||||
)
|
)
|
||||||
|
|
||||||
private class Element(str: String, val flags: Int) {
|
private class Element(str: String, val flags: Int) {
|
||||||
|
|
||||||
val upperCase = str.toUpperCase(Locale.ROOT)
|
val upperCase = str.toUpperCase(Locale.ROOT)
|
||||||
val lowerCase = str.toLowerCase(Locale.ROOT)
|
val lowerCase = str.toLowerCase(Locale.ROOT)
|
||||||
val length = str.length
|
val length = str.length
|
||||||
val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR }
|
val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a random human-readable password of length [targetLength], taking the following
|
* Generates a random human-readable password of length [targetLength], taking the following flags
|
||||||
* flags in [pwFlags] into account, or fails to do so and returns null:
|
* in [pwFlags] into account, or fails to do so and returns null:
|
||||||
*
|
*
|
||||||
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not
|
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set,
|
||||||
* set, the password will not contain any digits.
|
* the password will not contain any digits.
|
||||||
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase
|
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter;
|
||||||
* letter; if not set, the password will not contain any uppercase letters.
|
* if not set, the password will not contain any uppercase letters.
|
||||||
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase
|
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter;
|
||||||
* letter; if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any
|
* if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any lowercase
|
||||||
* lowercase characters; if both are not set, an exception is thrown.
|
* characters; if both are not set, an exception is thrown.
|
||||||
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
|
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
|
||||||
* set, the password will not contain any symbols.
|
* set, the password will not contain any symbols.
|
||||||
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
|
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
|
||||||
* characters.
|
* characters.
|
||||||
*/
|
*/
|
||||||
fun generate(targetLength: Int, pwFlags: Int): String? {
|
fun generate(targetLength: Int, pwFlags: Int): String? {
|
||||||
require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS)
|
require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS)
|
||||||
|
|
||||||
var password = ""
|
var password = ""
|
||||||
|
|
||||||
var isStartOfPart = true
|
var isStartOfPart = true
|
||||||
var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
|
var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
|
||||||
var previousFlags = 0
|
var previousFlags = 0
|
||||||
|
|
||||||
while (password.length < targetLength) {
|
while (password.length < targetLength) {
|
||||||
// First part: Add a single letter or pronounceable pair of letters in varying case.
|
// First part: Add a single letter or pronounceable pair of letters in varying case.
|
||||||
|
|
||||||
val candidate = elements.secureRandomElement()
|
val candidate = elements.secureRandomElement()
|
||||||
|
|
||||||
// Reroll if the candidate does not fulfill the current requirements.
|
// Reroll if the candidate does not fulfill the current requirements.
|
||||||
if (!candidate.flags.hasFlag(nextBasicType) ||
|
if (!candidate.flags.hasFlag(nextBasicType) ||
|
||||||
(isStartOfPart && candidate.flags hasFlag NOT_FIRST) ||
|
(isStartOfPart && candidate.flags hasFlag NOT_FIRST) ||
|
||||||
// Don't let a diphthong that starts with a vowel follow a vowel.
|
// Don't let a diphthong that starts with a vowel follow a vowel.
|
||||||
(previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) ||
|
(previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) ||
|
||||||
// Don't add multi-character candidates if we would go over the targetLength.
|
// Don't add multi-character candidates if we would go over the targetLength.
|
||||||
(password.length + candidate.length > targetLength) ||
|
(password.length + candidate.length > targetLength) ||
|
||||||
(pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)) {
|
(pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)
|
||||||
continue
|
) {
|
||||||
}
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// At this point the candidate could be appended to the password, but we still have
|
// At this point the candidate could be appended to the password, but we still have
|
||||||
// to determine the case. If no upper case characters are required, we don't add
|
// to determine the case. If no upper case characters are required, we don't add
|
||||||
// any.
|
// any.
|
||||||
val useUpperIfBothCasesAllowed =
|
val useUpperIfBothCasesAllowed =
|
||||||
(isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20)
|
(isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20)
|
||||||
password += if (pwFlags hasFlag PasswordGenerator.UPPERS &&
|
password +=
|
||||||
(!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)) {
|
if (pwFlags hasFlag PasswordGenerator.UPPERS &&
|
||||||
candidate.upperCase
|
(!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)
|
||||||
} else {
|
) {
|
||||||
candidate.lowerCase
|
candidate.upperCase
|
||||||
}
|
} else {
|
||||||
|
candidate.lowerCase
|
||||||
// We ensured above that we will not go above the target length.
|
|
||||||
check(password.length <= targetLength)
|
|
||||||
if (password.length == targetLength)
|
|
||||||
break
|
|
||||||
|
|
||||||
// Second part: Add digits and symbols with a certain probability (if requested) if
|
|
||||||
// they would not directly follow the first character in a pronounceable part.
|
|
||||||
|
|
||||||
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS &&
|
|
||||||
secureRandomBiasedBoolean(30)) {
|
|
||||||
var randomDigit: Char
|
|
||||||
do {
|
|
||||||
randomDigit = secureRandomNumber(10).toString(10).first()
|
|
||||||
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
|
|
||||||
randomDigit in PasswordGenerator.AMBIGUOUS_STR)
|
|
||||||
|
|
||||||
password += randomDigit
|
|
||||||
// Begin a new pronounceable part after every digit.
|
|
||||||
isStartOfPart = true
|
|
||||||
nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
|
|
||||||
previousFlags = 0
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS &&
|
|
||||||
secureRandomBiasedBoolean(20)) {
|
|
||||||
var randomSymbol: Char
|
|
||||||
do {
|
|
||||||
randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter()
|
|
||||||
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
|
|
||||||
randomSymbol in PasswordGenerator.AMBIGUOUS_STR)
|
|
||||||
password += randomSymbol
|
|
||||||
// Continue the password generation as if nothing was added.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Third part: Determine the basic type of the next character depending on the letter
|
|
||||||
// we just added.
|
|
||||||
nextBasicType = when {
|
|
||||||
candidate.flags.hasFlag(CONSONANT) -> VOWEL
|
|
||||||
previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) ||
|
|
||||||
secureRandomBiasedBoolean(60) -> CONSONANT
|
|
||||||
else -> VOWEL
|
|
||||||
}
|
|
||||||
previousFlags = candidate.flags
|
|
||||||
isStartOfPart = false
|
|
||||||
}
|
}
|
||||||
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
|
|
||||||
|
// We ensured above that we will not go above the target length.
|
||||||
|
check(password.length <= targetLength)
|
||||||
|
if (password.length == targetLength) break
|
||||||
|
|
||||||
|
// Second part: Add digits and symbols with a certain probability (if requested) if
|
||||||
|
// they would not directly follow the first character in a pronounceable part.
|
||||||
|
|
||||||
|
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS && secureRandomBiasedBoolean(30)) {
|
||||||
|
var randomDigit: Char
|
||||||
|
do {
|
||||||
|
randomDigit = secureRandomNumber(10).toString(10).first()
|
||||||
|
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && randomDigit in PasswordGenerator.AMBIGUOUS_STR)
|
||||||
|
|
||||||
|
password += randomDigit
|
||||||
|
// Begin a new pronounceable part after every digit.
|
||||||
|
isStartOfPart = true
|
||||||
|
nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
|
||||||
|
previousFlags = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS && secureRandomBiasedBoolean(20)) {
|
||||||
|
var randomSymbol: Char
|
||||||
|
do {
|
||||||
|
randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter()
|
||||||
|
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && randomSymbol in PasswordGenerator.AMBIGUOUS_STR)
|
||||||
|
password += randomSymbol
|
||||||
|
// Continue the password generation as if nothing was added.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third part: Determine the basic type of the next character depending on the letter
|
||||||
|
// we just added.
|
||||||
|
nextBasicType =
|
||||||
|
when {
|
||||||
|
candidate.flags.hasFlag(CONSONANT) -> VOWEL
|
||||||
|
previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) || secureRandomBiasedBoolean(60) ->
|
||||||
|
CONSONANT
|
||||||
|
else -> VOWEL
|
||||||
|
}
|
||||||
|
previousFlags = candidate.flags
|
||||||
|
isStartOfPart = false
|
||||||
}
|
}
|
||||||
|
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,5 +5,9 @@
|
||||||
package dev.msfjarvis.aps.util.pwgenxkpwd
|
package dev.msfjarvis.aps.util.pwgenxkpwd
|
||||||
|
|
||||||
enum class CapsType {
|
enum class CapsType {
|
||||||
lowercase, UPPERCASE, TitleCase, Sentence, As_iS
|
lowercase,
|
||||||
|
UPPERCASE,
|
||||||
|
TitleCase,
|
||||||
|
Sentence,
|
||||||
|
As_iS
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,127 +16,120 @@ import java.util.Locale
|
||||||
|
|
||||||
class PasswordBuilder(ctx: Context) {
|
class PasswordBuilder(ctx: Context) {
|
||||||
|
|
||||||
private var numSymbols = 0
|
private var numSymbols = 0
|
||||||
private var isAppendSymbolsSeparator = false
|
private var isAppendSymbolsSeparator = false
|
||||||
private var context = ctx
|
private var context = ctx
|
||||||
private var numWords = 3
|
private var numWords = 3
|
||||||
private var maxWordLength = 9
|
private var maxWordLength = 9
|
||||||
private var minWordLength = 5
|
private var minWordLength = 5
|
||||||
private var separator = "."
|
private var separator = "."
|
||||||
private var capsType = CapsType.Sentence
|
private var capsType = CapsType.Sentence
|
||||||
private var prependDigits = 0
|
private var prependDigits = 0
|
||||||
private var numDigits = 0
|
private var numDigits = 0
|
||||||
private var isPrependWithSeparator = false
|
private var isPrependWithSeparator = false
|
||||||
private var isAppendNumberSeparator = false
|
private var isAppendNumberSeparator = false
|
||||||
|
|
||||||
fun setNumberOfWords(amount: Int) = apply {
|
fun setNumberOfWords(amount: Int) = apply { numWords = amount }
|
||||||
numWords = amount
|
|
||||||
|
fun setMinimumWordLength(min: Int) = apply { minWordLength = min }
|
||||||
|
|
||||||
|
fun setMaximumWordLength(max: Int) = apply { maxWordLength = max }
|
||||||
|
|
||||||
|
fun setSeparator(separator: String) = apply { this.separator = separator }
|
||||||
|
|
||||||
|
fun setCapitalization(capitalizationScheme: CapsType) = apply { capsType = capitalizationScheme }
|
||||||
|
|
||||||
|
@JvmOverloads
|
||||||
|
fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply {
|
||||||
|
prependDigits = numDigits
|
||||||
|
isPrependWithSeparator = addSeparator
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmOverloads
|
||||||
|
fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply {
|
||||||
|
this.numDigits = numDigits
|
||||||
|
isAppendNumberSeparator = addSeparator
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmOverloads
|
||||||
|
fun appendSymbols(numSymbols: Int, addSeparator: Boolean = false) = apply {
|
||||||
|
this.numSymbols = numSymbols
|
||||||
|
isAppendSymbolsSeparator = addSeparator
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateRandomNumberSequence(totalNumbers: Int): String {
|
||||||
|
val numbers = StringBuilder(totalNumbers)
|
||||||
|
for (i in 0 until totalNumbers) {
|
||||||
|
numbers.append(secureRandomNumber(10))
|
||||||
}
|
}
|
||||||
|
return numbers.toString()
|
||||||
|
}
|
||||||
|
|
||||||
fun setMinimumWordLength(min: Int) = apply {
|
private fun generateRandomSymbolSequence(numSymbols: Int): String {
|
||||||
minWordLength = min
|
val numbers = StringBuilder(numSymbols)
|
||||||
|
for (i in 0 until numSymbols) {
|
||||||
|
numbers.append(SYMBOLS.secureRandomCharacter())
|
||||||
}
|
}
|
||||||
|
return numbers.toString()
|
||||||
|
}
|
||||||
|
|
||||||
fun setMaximumWordLength(max: Int) = apply {
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
maxWordLength = max
|
fun create(): Result<String, Throwable> {
|
||||||
|
val wordBank = mutableListOf<String>()
|
||||||
|
val password = StringBuilder()
|
||||||
|
|
||||||
|
if (prependDigits != 0) {
|
||||||
|
password.append(generateRandomNumberSequence(prependDigits))
|
||||||
|
if (isPrependWithSeparator) {
|
||||||
|
password.append(separator)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return runCatching {
|
||||||
|
val dictionary = XkpwdDictionary(context)
|
||||||
|
val words = dictionary.words
|
||||||
|
for (wordLength in minWordLength..maxWordLength) {
|
||||||
|
wordBank.addAll(words[wordLength] ?: emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
fun setSeparator(separator: String) = apply {
|
if (wordBank.size == 0) {
|
||||||
this.separator = separator
|
throw PasswordGeneratorException(
|
||||||
}
|
context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun setCapitalization(capitalizationScheme: CapsType) = apply {
|
for (i in 0 until numWords) {
|
||||||
capsType = capitalizationScheme
|
val candidate = wordBank.secureRandomElement()
|
||||||
}
|
val s =
|
||||||
|
when (capsType) {
|
||||||
@JvmOverloads
|
CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault())
|
||||||
fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply {
|
CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate
|
||||||
prependDigits = numDigits
|
CapsType.TitleCase -> candidate.capitalize(Locale.getDefault())
|
||||||
isPrependWithSeparator = addSeparator
|
CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault())
|
||||||
}
|
CapsType.As_iS -> candidate
|
||||||
|
}
|
||||||
@JvmOverloads
|
password.append(s)
|
||||||
fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply {
|
if (i + 1 < numWords) {
|
||||||
this.numDigits = numDigits
|
password.append(separator)
|
||||||
isAppendNumberSeparator = addSeparator
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmOverloads
|
|
||||||
fun appendSymbols(numSymbols: Int, addSeparator: Boolean = false) = apply {
|
|
||||||
this.numSymbols = numSymbols
|
|
||||||
isAppendSymbolsSeparator = addSeparator
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun generateRandomNumberSequence(totalNumbers: Int): String {
|
|
||||||
val numbers = StringBuilder(totalNumbers)
|
|
||||||
for (i in 0 until totalNumbers) {
|
|
||||||
numbers.append(secureRandomNumber(10))
|
|
||||||
}
|
}
|
||||||
return numbers.toString()
|
}
|
||||||
}
|
if (numDigits != 0) {
|
||||||
|
if (isAppendNumberSeparator) {
|
||||||
private fun generateRandomSymbolSequence(numSymbols: Int): String {
|
password.append(separator)
|
||||||
val numbers = StringBuilder(numSymbols)
|
|
||||||
for (i in 0 until numSymbols) {
|
|
||||||
numbers.append(SYMBOLS.secureRandomCharacter())
|
|
||||||
}
|
}
|
||||||
return numbers.toString()
|
password.append(generateRandomNumberSequence(numDigits))
|
||||||
}
|
}
|
||||||
|
if (numSymbols != 0) {
|
||||||
@OptIn(ExperimentalStdlibApi::class)
|
if (isAppendSymbolsSeparator) {
|
||||||
fun create(): Result<String, Throwable> {
|
password.append(separator)
|
||||||
val wordBank = mutableListOf<String>()
|
|
||||||
val password = StringBuilder()
|
|
||||||
|
|
||||||
if (prependDigits != 0) {
|
|
||||||
password.append(generateRandomNumberSequence(prependDigits))
|
|
||||||
if (isPrependWithSeparator) {
|
|
||||||
password.append(separator)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return runCatching {
|
|
||||||
val dictionary = XkpwdDictionary(context)
|
|
||||||
val words = dictionary.words
|
|
||||||
for (wordLength in minWordLength..maxWordLength) {
|
|
||||||
wordBank.addAll(words[wordLength] ?: emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wordBank.size == 0) {
|
|
||||||
throw PasswordGeneratorException(context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i in 0 until numWords) {
|
|
||||||
val candidate = wordBank.secureRandomElement()
|
|
||||||
val s = when (capsType) {
|
|
||||||
CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault())
|
|
||||||
CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate
|
|
||||||
CapsType.TitleCase -> candidate.capitalize(Locale.getDefault())
|
|
||||||
CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault())
|
|
||||||
CapsType.As_iS -> candidate
|
|
||||||
}
|
|
||||||
password.append(s)
|
|
||||||
if (i + 1 < numWords) {
|
|
||||||
password.append(separator)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (numDigits != 0) {
|
|
||||||
if (isAppendNumberSeparator) {
|
|
||||||
password.append(separator)
|
|
||||||
}
|
|
||||||
password.append(generateRandomNumberSequence(numDigits))
|
|
||||||
}
|
|
||||||
if (numSymbols != 0) {
|
|
||||||
if (isAppendSymbolsSeparator) {
|
|
||||||
password.append(separator)
|
|
||||||
}
|
|
||||||
password.append(generateRandomSymbolSequence(numSymbols))
|
|
||||||
}
|
|
||||||
password.toString()
|
|
||||||
}
|
}
|
||||||
|
password.append(generateRandomSymbolSequence(numSymbols))
|
||||||
|
}
|
||||||
|
password.toString()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#"
|
private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,28 +13,28 @@ import java.io.File
|
||||||
|
|
||||||
class XkpwdDictionary(context: Context) {
|
class XkpwdDictionary(context: Context) {
|
||||||
|
|
||||||
val words: Map<Int, List<String>>
|
val words: Map<Int, List<String>>
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val prefs = context.sharedPrefs
|
val prefs = context.sharedPrefs
|
||||||
val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: ""
|
val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: ""
|
||||||
val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE)
|
val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE)
|
||||||
|
|
||||||
val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) &&
|
val lines =
|
||||||
uri.isNotEmpty() && customDictFile.canRead()) {
|
if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) &&
|
||||||
customDictFile.readLines()
|
uri.isNotEmpty() &&
|
||||||
} else {
|
customDictFile.canRead()
|
||||||
context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines()
|
) {
|
||||||
}
|
customDictFile.readLines()
|
||||||
|
} else {
|
||||||
|
context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines()
|
||||||
|
}
|
||||||
|
|
||||||
words = lines.asSequence()
|
words = lines.asSequence().map { it.trim() }.filter { it.isNotEmpty() && !it.contains(' ') }.groupBy { it.length }
|
||||||
.map { it.trim() }
|
}
|
||||||
.filter { it.isNotEmpty() && !it.contains(' ') }
|
|
||||||
.groupBy { it.length }
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt"
|
const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,155 +32,150 @@ import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class ClipboardService : Service() {
|
class ClipboardService : Service() {
|
||||||
|
|
||||||
private val scope = CoroutineScope(Job() + Dispatchers.Main)
|
private val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
if (intent != null) {
|
if (intent != null) {
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_CLEAR -> {
|
ACTION_CLEAR -> {
|
||||||
clearClipboard()
|
clearClipboard()
|
||||||
stopForeground(true)
|
stopForeground(true)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return super.onStartCommand(intent, flags, startId)
|
return super.onStartCommand(intent, flags, startId)
|
||||||
}
|
}
|
||||||
|
ACTION_START -> {
|
||||||
|
val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45)
|
||||||
|
|
||||||
ACTION_START -> {
|
if (time == 0) {
|
||||||
val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45)
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
if (time == 0) {
|
createNotification(time)
|
||||||
stopSelf()
|
scope.launch {
|
||||||
}
|
withContext(Dispatchers.IO) { startTimer(time) }
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
createNotification(time)
|
clearClipboard()
|
||||||
scope.launch {
|
stopForeground(true)
|
||||||
withContext(Dispatchers.IO) {
|
stopSelf()
|
||||||
startTimer(time)
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
clearClipboard()
|
|
||||||
stopForeground(true)
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return super.onStartCommand(intent, flags, startId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? {
|
return super.onStartCommand(intent, flags, startId)
|
||||||
return null
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onBind(intent: Intent?): IBinder? {
|
||||||
scope.cancel()
|
return null
|
||||||
super.onDestroy()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearClipboard() {
|
override fun onDestroy() {
|
||||||
val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false)
|
scope.cancel()
|
||||||
val clipboard = clipboard
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
if (clipboard != null) {
|
private fun clearClipboard() {
|
||||||
scope.launch {
|
val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false)
|
||||||
d { "Clearing the clipboard" }
|
val clipboard = clipboard
|
||||||
val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
|
|
||||||
clipboard.setPrimaryClip(clip)
|
if (clipboard != null) {
|
||||||
if (deepClear) {
|
scope.launch {
|
||||||
withContext(Dispatchers.IO) {
|
d { "Clearing the clipboard" }
|
||||||
repeat(CLIPBOARD_CLEAR_COUNT) {
|
val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
|
||||||
val count = (it * 500).toString()
|
clipboard.setPrimaryClip(clip)
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText(count, count))
|
if (deepClear) {
|
||||||
}
|
withContext(Dispatchers.IO) {
|
||||||
}
|
repeat(CLIPBOARD_CLEAR_COUNT) {
|
||||||
}
|
val count = (it * 500).toString()
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText(count, count))
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
d { "Cannot get clipboard manager service" }
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
d { "Cannot get clipboard manager service" }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun startTimer(showTime: Int) {
|
private suspend fun startTimer(showTime: Int) {
|
||||||
var current = 0
|
var current = 0
|
||||||
while (scope.isActive && current < showTime) {
|
while (scope.isActive && current < showTime) {
|
||||||
// Block for 1s or until cancel is signalled
|
// Block for 1s or until cancel is signalled
|
||||||
current++
|
current++
|
||||||
delay(1000)
|
delay(1000)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotification(clearTime: Int) {
|
private fun createNotification(clearTime: Int) {
|
||||||
val clearTimeMs = clearTime * 1000L
|
val clearTimeMs = clearTime * 1000L
|
||||||
val clearIntent = Intent(this, ClipboardService::class.java).apply {
|
val clearIntent = Intent(this, ClipboardService::class.java).apply { action = ACTION_CLEAR }
|
||||||
action = ACTION_CLEAR
|
val pendingIntent =
|
||||||
}
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
} else {
|
||||||
} else {
|
PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||||
PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
}
|
||||||
}
|
val notification =
|
||||||
val notification = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
|
||||||
createNotificationApi23(pendingIntent)
|
createNotificationApi23(pendingIntent)
|
||||||
} else {
|
} else {
|
||||||
createNotificationApi24(pendingIntent, clearTimeMs)
|
createNotificationApi24(pendingIntent, clearTimeMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
createNotificationChannel()
|
createNotificationChannel()
|
||||||
startForeground(1, notification)
|
startForeground(1, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationApi23(pendingIntent: PendingIntent): Notification {
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.app_name))
|
||||||
|
.setContentText(getString(R.string.tap_clear_clipboard))
|
||||||
|
.setSmallIcon(R.drawable.ic_action_secure_24dp)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setUsesChronometer(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.N)
|
||||||
|
private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification {
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.app_name))
|
||||||
|
.setContentText(getString(R.string.tap_clear_clipboard))
|
||||||
|
.setSmallIcon(R.drawable.ic_action_secure_24dp)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setUsesChronometer(true)
|
||||||
|
.setChronometerCountDown(true)
|
||||||
|
.setShowWhen(true)
|
||||||
|
.setWhen(System.currentTimeMillis() + clearTimeMs)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val serviceChannel =
|
||||||
|
NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW)
|
||||||
|
val manager = getSystemService<NotificationManager>()
|
||||||
|
if (manager != null) {
|
||||||
|
manager.createNotificationChannel(serviceChannel)
|
||||||
|
} else {
|
||||||
|
d { "Failed to create notification channel" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotificationApi23(pendingIntent: PendingIntent): Notification {
|
companion object {
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(getString(R.string.app_name))
|
|
||||||
.setContentText(getString(R.string.tap_clear_clipboard))
|
|
||||||
.setSmallIcon(R.drawable.ic_action_secure_24dp)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.setUsesChronometer(true)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.N)
|
const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER"
|
||||||
private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification {
|
const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME"
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
|
||||||
.setContentTitle(getString(R.string.app_name))
|
private const val CHANNEL_ID = "NotificationService"
|
||||||
.setContentText(getString(R.string.tap_clear_clipboard))
|
// Newest Samsung phones now feature a history of up to 30 items. To err on the side of
|
||||||
.setSmallIcon(R.drawable.ic_action_secure_24dp)
|
// caution,
|
||||||
.setContentIntent(pendingIntent)
|
// push 35 fake ones.
|
||||||
.setUsesChronometer(true)
|
private const val CLIPBOARD_CLEAR_COUNT = 35
|
||||||
.setChronometerCountDown(true)
|
}
|
||||||
.setShowWhen(true)
|
|
||||||
.setWhen(System.currentTimeMillis() + clearTimeMs)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val serviceChannel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
getString(R.string.app_name),
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
)
|
|
||||||
val manager = getSystemService<NotificationManager>()
|
|
||||||
if (manager != null) {
|
|
||||||
manager.createNotificationChannel(serviceChannel)
|
|
||||||
} else {
|
|
||||||
d { "Failed to create notification channel" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER"
|
|
||||||
const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME"
|
|
||||||
private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
|
|
||||||
private const val CHANNEL_ID = "NotificationService"
|
|
||||||
// Newest Samsung phones now feature a history of up to 30 items. To err on the side of caution,
|
|
||||||
// push 35 fake ones.
|
|
||||||
private const val CLIPBOARD_CLEAR_COUNT = 35
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,108 +38,121 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
class OreoAutofillService : AutofillService() {
|
class OreoAutofillService : AutofillService() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
// TODO: Provide a user-configurable denylist
|
// TODO: Provide a user-configurable denylist
|
||||||
private val DENYLISTED_PACKAGES = listOf(
|
private val DENYLISTED_PACKAGES =
|
||||||
BuildConfig.APPLICATION_ID,
|
listOf(
|
||||||
"android",
|
BuildConfig.APPLICATION_ID,
|
||||||
"com.android.settings",
|
"android",
|
||||||
"com.android.settings.intelligence",
|
"com.android.settings",
|
||||||
"com.android.systemui",
|
"com.android.settings.intelligence",
|
||||||
"com.oneplus.applocker",
|
"com.android.systemui",
|
||||||
"org.sufficientlysecure.keychain",
|
"com.oneplus.applocker",
|
||||||
)
|
"org.sufficientlysecure.keychain",
|
||||||
|
)
|
||||||
|
|
||||||
private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L
|
private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
cachePublicSuffixList(applicationContext)
|
cachePublicSuffixList(applicationContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFillRequest(
|
override fun onFillRequest(request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback) {
|
||||||
request: FillRequest,
|
val structure =
|
||||||
cancellationSignal: CancellationSignal,
|
request.fillContexts.lastOrNull()?.structure
|
||||||
callback: FillCallback
|
?: run {
|
||||||
) {
|
callback.onSuccess(null)
|
||||||
val structure = request.fillContexts.lastOrNull()?.structure ?: run {
|
return
|
||||||
callback.onSuccess(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
|
|
||||||
if (Build.VERSION.SDK_INT >= 28) {
|
|
||||||
callback.onSuccess(FillResponse.Builder().run {
|
|
||||||
disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
|
|
||||||
build()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
callback.onSuccess(null)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val formToFill = FillableForm.parseAssistStructure(
|
|
||||||
this, structure,
|
|
||||||
isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST,
|
|
||||||
getCustomSuffixes(),
|
|
||||||
) ?: run {
|
|
||||||
d { "Form cannot be filled" }
|
|
||||||
callback.onSuccess(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback)
|
|
||||||
} else {
|
|
||||||
AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
|
|
||||||
// SaveCallback's behavior and feature set differs based on both target and device SDK, so
|
|
||||||
// we replace it with a wrapper that works the same in all situations.
|
|
||||||
@Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback)
|
|
||||||
val structure = request.fillContexts.lastOrNull()?.structure ?: run {
|
|
||||||
callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val clientState = request.clientState ?: run {
|
|
||||||
e { "Received save request without client state" }
|
|
||||||
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val scenario = AutofillScenario.fromClientState(clientState)?.recoverNodes(structure)
|
|
||||||
?: run {
|
|
||||||
e { "Failed to recover client state or nodes from client state" }
|
|
||||||
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val formOrigin = FormOrigin.fromBundle(clientState) ?: run {
|
|
||||||
e { "Failed to recover form origin from client state" }
|
|
||||||
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val username = scenario.usernameValue
|
|
||||||
val password = scenario.passwordValue ?: run {
|
|
||||||
callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match))
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) {
|
||||||
callback.onSuccess(
|
callback.onSuccess(
|
||||||
AutofillSaveActivity.makeSaveIntentSender(
|
FillResponse.Builder().run {
|
||||||
this,
|
disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
|
||||||
credentials = Credentials(username, password, null),
|
build()
|
||||||
formOrigin = formOrigin
|
}
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
callback.onSuccess(null)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
val formToFill =
|
||||||
|
FillableForm.parseAssistStructure(
|
||||||
|
this,
|
||||||
|
structure,
|
||||||
|
isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST,
|
||||||
|
getCustomSuffixes(),
|
||||||
|
)
|
||||||
|
?: run {
|
||||||
|
d { "Form cannot be filled" }
|
||||||
|
callback.onSuccess(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback)
|
||||||
|
} else {
|
||||||
|
AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
|
||||||
|
// SaveCallback's behavior and feature set differs based on both target and device SDK, so
|
||||||
|
// we replace it with a wrapper that works the same in all situations.
|
||||||
|
@Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback)
|
||||||
|
val structure =
|
||||||
|
request.fillContexts.lastOrNull()?.structure
|
||||||
|
?: run {
|
||||||
|
callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val clientState =
|
||||||
|
request.clientState
|
||||||
|
?: run {
|
||||||
|
e { "Received save request without client state" }
|
||||||
|
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val scenario =
|
||||||
|
AutofillScenario.fromClientState(clientState)?.recoverNodes(structure)
|
||||||
|
?: run {
|
||||||
|
e { "Failed to recover client state or nodes from client state" }
|
||||||
|
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val formOrigin =
|
||||||
|
FormOrigin.fromBundle(clientState)
|
||||||
|
?: run {
|
||||||
|
e { "Failed to recover form origin from client state" }
|
||||||
|
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val username = scenario.usernameValue
|
||||||
|
val password =
|
||||||
|
scenario.passwordValue
|
||||||
|
?: run {
|
||||||
|
callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback.onSuccess(
|
||||||
|
AutofillSaveActivity.makeSaveIntentSender(
|
||||||
|
this,
|
||||||
|
credentials = Credentials(username, password, null),
|
||||||
|
formOrigin = formOrigin
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
|
fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
|
||||||
|
|
||||||
fun Context.getCustomSuffixes(): Sequence<String> {
|
fun Context.getCustomSuffixes(): Sequence<String> {
|
||||||
return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
|
return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)?.splitToSequence('\n')?.filter {
|
||||||
?.splitToSequence('\n')
|
it.isNotBlank() && it.first() != '.' && it.last() != '.'
|
||||||
?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
|
}
|
||||||
?: emptySequence()
|
?: emptySequence()
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,134 +25,131 @@ import java.util.TimeZone
|
||||||
|
|
||||||
class PasswordExportService : Service() {
|
class PasswordExportService : Service() {
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
if (intent != null) {
|
if (intent != null) {
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_EXPORT_PASSWORD -> {
|
ACTION_EXPORT_PASSWORD -> {
|
||||||
val uri = intent.getParcelableExtra<Uri>("uri")
|
val uri = intent.getParcelableExtra<Uri>("uri")
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
|
val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
|
||||||
|
|
||||||
if (targetDirectory != null) {
|
if (targetDirectory != null) {
|
||||||
createNotification()
|
createNotification()
|
||||||
exportPasswords(targetDirectory)
|
exportPasswords(targetDirectory)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return super.onStartCommand(intent, flags, startId)
|
}
|
||||||
}
|
}
|
||||||
|
return super.onStartCommand(intent, flags, startId)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? {
|
override fun onBind(intent: Intent?): IBinder? {
|
||||||
return null
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports passwords to the given directory.
|
||||||
|
*
|
||||||
|
* Recursively copies the existing password store to an external directory.
|
||||||
|
*
|
||||||
|
* @param targetDirectory directory to copy password directory to.
|
||||||
|
*/
|
||||||
|
private fun exportPasswords(targetDirectory: DocumentFile) {
|
||||||
|
|
||||||
|
val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory())
|
||||||
|
val sourcePassDir = DocumentFile.fromFile(repositoryDirectory)
|
||||||
|
|
||||||
|
d { "Copying ${repositoryDirectory.path} to $targetDirectory" }
|
||||||
|
|
||||||
|
val dateString =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
|
||||||
|
} else {
|
||||||
|
String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z")))
|
||||||
|
}
|
||||||
|
|
||||||
|
val passDir = targetDirectory.createDirectory("password_store_$dateString")
|
||||||
|
|
||||||
|
if (passDir != null) {
|
||||||
|
copyDirToDir(sourcePassDir, passDir)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exports passwords to the given directory.
|
* Copies a password file to a given directory.
|
||||||
*
|
*
|
||||||
* Recursively copies the existing password store to an external directory.
|
* Note: this does not preserve last modified time.
|
||||||
*
|
*
|
||||||
* @param targetDirectory directory to copy password directory to.
|
* @param passwordFile password file to copy.
|
||||||
*/
|
* @param targetDirectory target directory to copy password.
|
||||||
private fun exportPasswords(targetDirectory: DocumentFile) {
|
*/
|
||||||
|
private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) {
|
||||||
|
val sourceInputStream = contentResolver.openInputStream(passwordFile.uri)
|
||||||
|
val name = passwordFile.name
|
||||||
|
val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!)
|
||||||
|
if (targetPasswordFile?.exists() == true) {
|
||||||
|
val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri)
|
||||||
|
|
||||||
val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory())
|
if (destOutputStream != null && sourceInputStream != null) {
|
||||||
val sourcePassDir = DocumentFile.fromFile(repositoryDirectory)
|
sourceInputStream.copyTo(destOutputStream, 1024)
|
||||||
|
|
||||||
d { "Copying ${repositoryDirectory.path} to $targetDirectory" }
|
sourceInputStream.close()
|
||||||
|
destOutputStream.close()
|
||||||
val dateString = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
}
|
||||||
LocalDateTime
|
|
||||||
.now()
|
|
||||||
.format(DateTimeFormatter.ISO_DATE_TIME)
|
|
||||||
} else {
|
|
||||||
String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z")))
|
|
||||||
}
|
|
||||||
|
|
||||||
val passDir = targetDirectory.createDirectory("password_store_$dateString")
|
|
||||||
|
|
||||||
if (passDir != null) {
|
|
||||||
copyDirToDir(sourcePassDir, passDir)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copies a password file to a given directory.
|
* Recursively copies a directory to a destination.
|
||||||
*
|
*
|
||||||
* Note: this does not preserve last modified time.
|
* @param sourceDirectory directory to copy from.
|
||||||
*
|
* @param targetDirectory directory to copy to.
|
||||||
* @param passwordFile password file to copy.
|
*/
|
||||||
* @param targetDirectory target directory to copy password.
|
private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) {
|
||||||
*/
|
sourceDirectory.listFiles().forEach { file ->
|
||||||
private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) {
|
if (file.isDirectory) {
|
||||||
val sourceInputStream = contentResolver.openInputStream(passwordFile.uri)
|
// Create new directory and recurse
|
||||||
val name = passwordFile.name
|
val newDir = targetDirectory.createDirectory(file.name!!)
|
||||||
val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!)
|
copyDirToDir(file, newDir!!)
|
||||||
if (targetPasswordFile?.exists() == true) {
|
} else {
|
||||||
val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri)
|
copyFileToDir(file, targetDirectory)
|
||||||
|
}
|
||||||
if (destOutputStream != null && sourceInputStream != null) {
|
|
||||||
sourceInputStream.copyTo(destOutputStream, 1024)
|
|
||||||
|
|
||||||
sourceInputStream.close()
|
|
||||||
destOutputStream.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private fun createNotification() {
|
||||||
* Recursively copies a directory to a destination.
|
createNotificationChannel()
|
||||||
*
|
|
||||||
* @param sourceDirectory directory to copy from.
|
val notification =
|
||||||
* @param targetDirectory directory to copy to.
|
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
*/
|
.setContentTitle(getString(R.string.app_name))
|
||||||
private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) {
|
.setContentText(getString(R.string.exporting_passwords))
|
||||||
sourceDirectory.listFiles().forEach { file ->
|
.setSmallIcon(R.drawable.ic_round_import_export)
|
||||||
if (file.isDirectory) {
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
// Create new directory and recurse
|
.build()
|
||||||
val newDir = targetDirectory.createDirectory(file.name!!)
|
|
||||||
copyDirToDir(file, newDir!!)
|
startForeground(2, notification)
|
||||||
} else {
|
}
|
||||||
copyFileToDir(file, targetDirectory)
|
|
||||||
}
|
private fun createNotificationChannel() {
|
||||||
}
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val serviceChannel =
|
||||||
|
NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW)
|
||||||
|
val manager = getSystemService<NotificationManager>()
|
||||||
|
if (manager != null) {
|
||||||
|
manager.createNotificationChannel(serviceChannel)
|
||||||
|
} else {
|
||||||
|
d { "Failed to create notification channel" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun createNotification() {
|
companion object {
|
||||||
createNotificationChannel()
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD"
|
||||||
.setContentTitle(getString(R.string.app_name))
|
private const val CHANNEL_ID = "NotificationService"
|
||||||
.setContentText(getString(R.string.exporting_passwords))
|
}
|
||||||
.setSmallIcon(R.drawable.ic_round_import_export)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
startForeground(2, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val serviceChannel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
getString(R.string.app_name),
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
)
|
|
||||||
val manager = getSystemService<NotificationManager>()
|
|
||||||
if (manager != null) {
|
|
||||||
manager.createNotificationChannel(serviceChannel)
|
|
||||||
} else {
|
|
||||||
d { "Failed to create notification channel" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD"
|
|
||||||
private const val CHANNEL_ID = "NotificationService"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,191 +17,168 @@ import java.io.File
|
||||||
import org.eclipse.jgit.transport.URIish
|
import org.eclipse.jgit.transport.URIish
|
||||||
|
|
||||||
enum class Protocol(val pref: String) {
|
enum class Protocol(val pref: String) {
|
||||||
Ssh("ssh://"),
|
Ssh("ssh://"),
|
||||||
Https("https://"),
|
Https("https://"),
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private val map = values().associateBy(Protocol::pref)
|
private val map = values().associateBy(Protocol::pref)
|
||||||
fun fromString(type: String?): Protocol {
|
fun fromString(type: String?): Protocol {
|
||||||
return map[type ?: return Ssh]
|
return map[type ?: return Ssh] ?: throw IllegalArgumentException("$type is not a valid Protocol")
|
||||||
?: throw IllegalArgumentException("$type is not a valid Protocol")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class AuthMode(val pref: String) {
|
enum class AuthMode(val pref: String) {
|
||||||
SshKey("ssh-key"),
|
SshKey("ssh-key"),
|
||||||
Password("username/password"),
|
Password("username/password"),
|
||||||
OpenKeychain("OpenKeychain"),
|
OpenKeychain("OpenKeychain"),
|
||||||
None("None"),
|
None("None"),
|
||||||
;
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
private val map = values().associateBy(AuthMode::pref)
|
private val map = values().associateBy(AuthMode::pref)
|
||||||
fun fromString(type: String?): AuthMode {
|
fun fromString(type: String?): AuthMode {
|
||||||
return map[type ?: return SshKey]
|
return map[type ?: return SshKey] ?: throw IllegalArgumentException("$type is not a valid AuthMode")
|
||||||
?: throw IllegalArgumentException("$type is not a valid AuthMode")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
object GitSettings {
|
object GitSettings {
|
||||||
|
|
||||||
private const val DEFAULT_BRANCH = "master"
|
private const val DEFAULT_BRANCH = "master"
|
||||||
|
|
||||||
private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs }
|
private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs }
|
||||||
private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedGitPrefs() }
|
private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) {
|
||||||
private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() }
|
Application.instance.getEncryptedGitPrefs()
|
||||||
private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" }
|
}
|
||||||
|
private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() }
|
||||||
|
private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" }
|
||||||
|
|
||||||
var authMode
|
var authMode
|
||||||
get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
|
get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
|
||||||
private set(value) {
|
private set(value) {
|
||||||
settings.edit {
|
settings.edit { putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) }
|
||||||
putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var url
|
|
||||||
get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL)
|
|
||||||
private set(value) {
|
|
||||||
require(value != null)
|
|
||||||
if (value == url)
|
|
||||||
return
|
|
||||||
settings.edit {
|
|
||||||
putString(PreferenceKeys.GIT_REMOTE_URL, value)
|
|
||||||
}
|
|
||||||
if (PasswordRepository.isInitialized)
|
|
||||||
PasswordRepository.addRemote("origin", value, true)
|
|
||||||
// When the server changes, remote password, multiplexing support and host key file
|
|
||||||
// should be deleted/reset.
|
|
||||||
useMultiplexing = true
|
|
||||||
encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
|
|
||||||
clearSavedHostKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
var authorName
|
|
||||||
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
|
|
||||||
set(value) {
|
|
||||||
settings.edit {
|
|
||||||
putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var authorEmail
|
|
||||||
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: ""
|
|
||||||
set(value) {
|
|
||||||
settings.edit {
|
|
||||||
putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var branch
|
|
||||||
get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH
|
|
||||||
private set(value) {
|
|
||||||
settings.edit {
|
|
||||||
putString(PreferenceKeys.GIT_BRANCH_NAME, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var useMultiplexing
|
|
||||||
get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true)
|
|
||||||
set(value) {
|
|
||||||
settings.edit {
|
|
||||||
putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var proxyHost
|
|
||||||
get() = proxySettings.getString(PreferenceKeys.PROXY_HOST)
|
|
||||||
set(value) {
|
|
||||||
proxySettings.edit {
|
|
||||||
putString(PreferenceKeys.PROXY_HOST, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var proxyPort
|
|
||||||
get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1)
|
|
||||||
set(value) {
|
|
||||||
proxySettings.edit {
|
|
||||||
putInt(PreferenceKeys.PROXY_PORT, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var proxyUsername
|
|
||||||
get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
|
|
||||||
set(value) {
|
|
||||||
proxySettings.edit {
|
|
||||||
putString(PreferenceKeys.PROXY_USERNAME, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var proxyPassword
|
|
||||||
get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD)
|
|
||||||
set(value) {
|
|
||||||
proxySettings.edit {
|
|
||||||
putString(PreferenceKeys.PROXY_PASSWORD, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var rebaseOnPull
|
|
||||||
get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true)
|
|
||||||
set(value) {
|
|
||||||
settings.edit {
|
|
||||||
putBoolean(PreferenceKeys.REBASE_ON_PULL, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed class UpdateConnectionSettingsResult {
|
|
||||||
class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
|
|
||||||
class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) : UpdateConnectionSettingsResult()
|
|
||||||
object Valid : UpdateConnectionSettingsResult()
|
|
||||||
object FailedToParseUrl : UpdateConnectionSettingsResult()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateConnectionSettingsIfValid(newAuthMode: AuthMode, newUrl: String, newBranch: String): UpdateConnectionSettingsResult {
|
var url
|
||||||
val parsedUrl = runCatching {
|
get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL)
|
||||||
URIish(newUrl)
|
private set(value) {
|
||||||
}.getOrElse {
|
require(value != null)
|
||||||
return UpdateConnectionSettingsResult.FailedToParseUrl
|
if (value == url) return
|
||||||
}
|
settings.edit { putString(PreferenceKeys.GIT_REMOTE_URL, value) }
|
||||||
val newProtocol = when (parsedUrl.scheme) {
|
if (PasswordRepository.isInitialized) PasswordRepository.addRemote("origin", value, true)
|
||||||
in listOf("http", "https") -> Protocol.Https
|
// When the server changes, remote password, multiplexing support and host key file
|
||||||
in listOf("ssh", null) -> Protocol.Ssh
|
// should be deleted/reset.
|
||||||
else -> return UpdateConnectionSettingsResult.FailedToParseUrl
|
useMultiplexing = true
|
||||||
}
|
encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
|
||||||
if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank())
|
clearSavedHostKey()
|
||||||
return UpdateConnectionSettingsResult.MissingUsername(newProtocol)
|
|
||||||
|
|
||||||
val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password)
|
|
||||||
val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey)
|
|
||||||
when {
|
|
||||||
newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> {
|
|
||||||
return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth)
|
|
||||||
}
|
|
||||||
newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> {
|
|
||||||
return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
url = newUrl
|
|
||||||
authMode = newAuthMode
|
|
||||||
branch = newBranch
|
|
||||||
return UpdateConnectionSettingsResult.Valid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
var authorName
|
||||||
* Deletes a previously saved SSH host key
|
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
|
||||||
*/
|
set(value) {
|
||||||
fun clearSavedHostKey() {
|
settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) }
|
||||||
File(hostKeyPath).delete()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
var authorEmail
|
||||||
* Returns true if a host key was previously saved
|
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: ""
|
||||||
*/
|
set(value) {
|
||||||
fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists()
|
settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var branch
|
||||||
|
get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH
|
||||||
|
private set(value) {
|
||||||
|
settings.edit { putString(PreferenceKeys.GIT_BRANCH_NAME, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var useMultiplexing
|
||||||
|
get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true)
|
||||||
|
set(value) {
|
||||||
|
settings.edit { putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxyHost
|
||||||
|
get() = proxySettings.getString(PreferenceKeys.PROXY_HOST)
|
||||||
|
set(value) {
|
||||||
|
proxySettings.edit { putString(PreferenceKeys.PROXY_HOST, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxyPort
|
||||||
|
get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1)
|
||||||
|
set(value) {
|
||||||
|
proxySettings.edit { putInt(PreferenceKeys.PROXY_PORT, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxyUsername
|
||||||
|
get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
|
||||||
|
set(value) {
|
||||||
|
proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var proxyPassword
|
||||||
|
get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD)
|
||||||
|
set(value) {
|
||||||
|
proxySettings.edit { putString(PreferenceKeys.PROXY_PASSWORD, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var rebaseOnPull
|
||||||
|
get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true)
|
||||||
|
set(value) {
|
||||||
|
settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class UpdateConnectionSettingsResult {
|
||||||
|
class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
|
||||||
|
class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) :
|
||||||
|
UpdateConnectionSettingsResult()
|
||||||
|
object Valid : UpdateConnectionSettingsResult()
|
||||||
|
object FailedToParseUrl : UpdateConnectionSettingsResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateConnectionSettingsIfValid(
|
||||||
|
newAuthMode: AuthMode,
|
||||||
|
newUrl: String,
|
||||||
|
newBranch: String
|
||||||
|
): UpdateConnectionSettingsResult {
|
||||||
|
val parsedUrl =
|
||||||
|
runCatching { URIish(newUrl) }.getOrElse {
|
||||||
|
return UpdateConnectionSettingsResult.FailedToParseUrl
|
||||||
|
}
|
||||||
|
val newProtocol =
|
||||||
|
when (parsedUrl.scheme) {
|
||||||
|
in listOf("http", "https") -> Protocol.Https
|
||||||
|
in listOf("ssh", null) -> Protocol.Ssh
|
||||||
|
else -> return UpdateConnectionSettingsResult.FailedToParseUrl
|
||||||
|
}
|
||||||
|
if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank())
|
||||||
|
return UpdateConnectionSettingsResult.MissingUsername(newProtocol)
|
||||||
|
|
||||||
|
val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password)
|
||||||
|
val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey)
|
||||||
|
when {
|
||||||
|
newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> {
|
||||||
|
return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth)
|
||||||
|
}
|
||||||
|
newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> {
|
||||||
|
return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
url = newUrl
|
||||||
|
authMode = newAuthMode
|
||||||
|
branch = newBranch
|
||||||
|
return UpdateConnectionSettingsResult.Valid
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes a previously saved SSH host key */
|
||||||
|
fun clearSavedHostKey() {
|
||||||
|
File(hostKeyPath).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if a host key was previously saved */
|
||||||
|
fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists()
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,108 +20,100 @@ import java.io.File
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
fun runMigrations(context: Context) {
|
fun runMigrations(context: Context) {
|
||||||
val sharedPrefs = context.sharedPrefs
|
val sharedPrefs = context.sharedPrefs
|
||||||
migrateToGitUrlBasedConfig(sharedPrefs)
|
migrateToGitUrlBasedConfig(sharedPrefs)
|
||||||
migrateToHideAll(sharedPrefs)
|
migrateToHideAll(sharedPrefs)
|
||||||
migrateToSshKey(context, sharedPrefs)
|
migrateToSshKey(context, sharedPrefs)
|
||||||
migrateToClipboardHistory(sharedPrefs)
|
migrateToClipboardHistory(sharedPrefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) {
|
private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) {
|
||||||
val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER)
|
val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) ?: return
|
||||||
?: return
|
i { "Migrating to URL-based Git config" }
|
||||||
i { "Migrating to URL-based Git config" }
|
val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: ""
|
||||||
val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: ""
|
val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: ""
|
||||||
val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: ""
|
val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: ""
|
||||||
val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: ""
|
val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
|
||||||
val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
|
|
||||||
|
|
||||||
// Whether we need the leading ssh:// depends on the use of a custom port.
|
// Whether we need the leading ssh:// depends on the use of a custom port.
|
||||||
val hostnamePart = serverHostname.removePrefix("ssh://")
|
val hostnamePart = serverHostname.removePrefix("ssh://")
|
||||||
val url = when (protocol) {
|
val url =
|
||||||
Protocol.Ssh -> {
|
when (protocol) {
|
||||||
val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@"
|
Protocol.Ssh -> {
|
||||||
val portPart =
|
val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@"
|
||||||
if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort"
|
val portPart = if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort"
|
||||||
if (portPart.isEmpty()) {
|
if (portPart.isEmpty()) {
|
||||||
"$userPart$hostnamePart:$serverPath"
|
"$userPart$hostnamePart:$serverPath"
|
||||||
} else {
|
} else {
|
||||||
// Only absolute paths are supported with custom ports.
|
// Only absolute paths are supported with custom ports.
|
||||||
if (!serverPath.startsWith('/'))
|
if (!serverPath.startsWith('/')) null
|
||||||
null
|
else
|
||||||
else
|
// We have to specify the ssh scheme as this is the only way to pass a custom
|
||||||
// We have to specify the ssh scheme as this is the only way to pass a custom
|
// port.
|
||||||
// port.
|
"ssh://$userPart$hostnamePart$portPart$serverPath"
|
||||||
"ssh://$userPart$hostnamePart$portPart$serverPath"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Protocol.Https -> {
|
|
||||||
val portPart =
|
|
||||||
if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort"
|
|
||||||
val pathPart = serverPath.trimStart('/', ':')
|
|
||||||
val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart"
|
|
||||||
val url = when {
|
|
||||||
urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme
|
|
||||||
urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https")
|
|
||||||
else -> "https://$urlWithFreeEntryScheme"
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
if (URI(url).rawAuthority != null)
|
|
||||||
url
|
|
||||||
else
|
|
||||||
null
|
|
||||||
}.get()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Protocol.Https -> {
|
||||||
|
val portPart = if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort"
|
||||||
|
val pathPart = serverPath.trimStart('/', ':')
|
||||||
|
val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart"
|
||||||
|
val url =
|
||||||
|
when {
|
||||||
|
urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme
|
||||||
|
urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https")
|
||||||
|
else -> "https://$urlWithFreeEntryScheme"
|
||||||
|
}
|
||||||
|
runCatching { if (URI(url).rawAuthority != null) url else null }.get()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sharedPrefs.edit {
|
sharedPrefs.edit {
|
||||||
remove(PreferenceKeys.GIT_REMOTE_LOCATION)
|
remove(PreferenceKeys.GIT_REMOTE_LOCATION)
|
||||||
remove(PreferenceKeys.GIT_REMOTE_PORT)
|
remove(PreferenceKeys.GIT_REMOTE_PORT)
|
||||||
remove(PreferenceKeys.GIT_REMOTE_SERVER)
|
remove(PreferenceKeys.GIT_REMOTE_SERVER)
|
||||||
remove(PreferenceKeys.GIT_REMOTE_USERNAME)
|
remove(PreferenceKeys.GIT_REMOTE_USERNAME)
|
||||||
remove(PreferenceKeys.GIT_REMOTE_PROTOCOL)
|
remove(PreferenceKeys.GIT_REMOTE_PROTOCOL)
|
||||||
}
|
}
|
||||||
if (url == null || GitSettings.updateConnectionSettingsIfValid(
|
if (url == null ||
|
||||||
newAuthMode = GitSettings.authMode,
|
GitSettings.updateConnectionSettingsIfValid(
|
||||||
newUrl = url,
|
newAuthMode = GitSettings.authMode,
|
||||||
newBranch = GitSettings.branch) != GitSettings.UpdateConnectionSettingsResult.Valid) {
|
newUrl = url,
|
||||||
e { "Failed to migrate to URL-based Git config, generated URL is invalid" }
|
newBranch = GitSettings.branch
|
||||||
}
|
) != GitSettings.UpdateConnectionSettingsResult.Valid
|
||||||
|
) {
|
||||||
|
e { "Failed to migrate to URL-based Git config, generated URL is invalid" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun migrateToHideAll(sharedPrefs: SharedPreferences) {
|
private fun migrateToHideAll(sharedPrefs: SharedPreferences) {
|
||||||
sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return
|
sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return
|
||||||
val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
|
val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
|
||||||
sharedPrefs.edit {
|
sharedPrefs.edit {
|
||||||
remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS)
|
remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS)
|
||||||
putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden)
|
putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) {
|
private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) {
|
||||||
val privateKeyFile = File(context.filesDir, ".ssh_key")
|
val privateKeyFile = File(context.filesDir, ".ssh_key")
|
||||||
if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) &&
|
if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && !SshKey.exists && privateKeyFile.exists()) {
|
||||||
!SshKey.exists &&
|
// Currently uses a private key imported or generated with an old version of Password Store.
|
||||||
privateKeyFile.exists()) {
|
// Generated keys come with a public key which the user should still be able to view after
|
||||||
// Currently uses a private key imported or generated with an old version of Password Store.
|
// the migration (not possible for regular imported keys), hence the special case.
|
||||||
// Generated keys come with a public key which the user should still be able to view after
|
val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
|
||||||
// the migration (not possible for regular imported keys), hence the special case.
|
SshKey.useLegacyKey(isGeneratedKey)
|
||||||
val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
|
sharedPrefs.edit { remove(PreferenceKeys.USE_GENERATED_KEY) }
|
||||||
SshKey.useLegacyKey(isGeneratedKey)
|
}
|
||||||
sharedPrefs.edit {
|
|
||||||
remove(PreferenceKeys.USE_GENERATED_KEY)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun migrateToClipboardHistory(sharedPrefs: SharedPreferences) {
|
private fun migrateToClipboardHistory(sharedPrefs: SharedPreferences) {
|
||||||
if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) {
|
if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) {
|
||||||
sharedPrefs.edit {
|
sharedPrefs.edit {
|
||||||
putBoolean(
|
putBoolean(
|
||||||
PreferenceKeys.CLEAR_CLIPBOARD_HISTORY,
|
PreferenceKeys.CLEAR_CLIPBOARD_HISTORY,
|
||||||
sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
|
sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
|
||||||
)
|
)
|
||||||
remove(PreferenceKeys.CLEAR_CLIPBOARD_20X)
|
remove(PreferenceKeys.CLEAR_CLIPBOARD_20X)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,37 +13,36 @@ import dev.msfjarvis.aps.util.extensions.base64
|
||||||
import dev.msfjarvis.aps.util.extensions.getString
|
import dev.msfjarvis.aps.util.extensions.getString
|
||||||
|
|
||||||
enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) {
|
enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) {
|
||||||
|
FOLDER_FIRST(
|
||||||
FOLDER_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem ->
|
Comparator { p1: PasswordItem, p2: PasswordItem ->
|
||||||
(p1.type + p1.name)
|
(p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true)
|
||||||
.compareTo(p2.type + p2.name, ignoreCase = true)
|
|
||||||
}),
|
|
||||||
|
|
||||||
INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem ->
|
|
||||||
p1.name.compareTo(p2.name, ignoreCase = true)
|
|
||||||
}),
|
|
||||||
|
|
||||||
RECENTLY_USED(Comparator { p1: PasswordItem, p2: PasswordItem ->
|
|
||||||
val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
|
||||||
val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
|
|
||||||
val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
|
|
||||||
when {
|
|
||||||
timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1)
|
|
||||||
timeP1 != null && timeP2 == null -> return@Comparator -1
|
|
||||||
timeP1 == null && timeP2 != null -> return@Comparator 1
|
|
||||||
else -> p1.name.compareTo(p2.name, ignoreCase = true)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
FILE_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem ->
|
|
||||||
(p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
|
|
||||||
});
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
|
|
||||||
return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem -> p1.name.compareTo(p2.name, ignoreCase = true) }),
|
||||||
|
RECENTLY_USED(
|
||||||
|
Comparator { p1: PasswordItem, p2: PasswordItem ->
|
||||||
|
val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
||||||
|
val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
|
||||||
|
val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
|
||||||
|
when {
|
||||||
|
timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1)
|
||||||
|
timeP1 != null && timeP2 == null -> return@Comparator -1
|
||||||
|
timeP1 == null && timeP2 != null -> return@Comparator 1
|
||||||
|
else -> p1.name.compareTo(p2.name, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
FILE_FIRST(
|
||||||
|
Comparator { p1: PasswordItem, p2: PasswordItem ->
|
||||||
|
(p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
|
||||||
|
return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,85 +7,79 @@ package dev.msfjarvis.aps.util.settings
|
||||||
|
|
||||||
object PreferenceKeys {
|
object PreferenceKeys {
|
||||||
|
|
||||||
const val APP_THEME = "app_theme"
|
const val APP_THEME = "app_theme"
|
||||||
const val APP_VERSION = "app_version"
|
const val APP_VERSION = "app_version"
|
||||||
const val AUTOFILL_ENABLE = "autofill_enable"
|
const val AUTOFILL_ENABLE = "autofill_enable"
|
||||||
const val BIOMETRIC_AUTH = "biometric_auth"
|
const val BIOMETRIC_AUTH = "biometric_auth"
|
||||||
@Deprecated(
|
@Deprecated(
|
||||||
message = "Use CLEAR_CLIPBOARD_HISTORY instead",
|
message = "Use CLEAR_CLIPBOARD_HISTORY instead",
|
||||||
replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"),
|
replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"),
|
||||||
)
|
)
|
||||||
const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x"
|
const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x"
|
||||||
const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history"
|
const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history"
|
||||||
const val CLEAR_SAVED_PASS = "clear_saved_pass"
|
const val CLEAR_SAVED_PASS = "clear_saved_pass"
|
||||||
const val COPY_ON_DECRYPT = "copy_on_decrypt"
|
const val COPY_ON_DECRYPT = "copy_on_decrypt"
|
||||||
const val ENABLE_DEBUG_LOGGING = "enable_debug_logging"
|
const val ENABLE_DEBUG_LOGGING = "enable_debug_logging"
|
||||||
const val EXPORT_PASSWORDS = "export_passwords"
|
const val EXPORT_PASSWORDS = "export_passwords"
|
||||||
const val FILTER_RECURSIVELY = "filter_recursively"
|
const val FILTER_RECURSIVELY = "filter_recursively"
|
||||||
const val GENERAL_SHOW_TIME = "general_show_time"
|
const val GENERAL_SHOW_TIME = "general_show_time"
|
||||||
const val GIT_CONFIG = "git_config"
|
const val GIT_CONFIG = "git_config"
|
||||||
const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email"
|
const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email"
|
||||||
const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name"
|
const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name"
|
||||||
const val GIT_EXTERNAL = "git_external"
|
const val GIT_EXTERNAL = "git_external"
|
||||||
const val GIT_EXTERNAL_REPO = "git_external_repo"
|
const val GIT_EXTERNAL_REPO = "git_external_repo"
|
||||||
const val GIT_REMOTE_AUTH = "git_remote_auth"
|
const val GIT_REMOTE_AUTH = "git_remote_auth"
|
||||||
const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type"
|
const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type"
|
||||||
|
|
||||||
@Deprecated("Use GIT_REMOTE_URL instead")
|
@Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_LOCATION = "git_remote_location"
|
||||||
const val GIT_REMOTE_LOCATION = "git_remote_location"
|
const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing"
|
||||||
const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing"
|
|
||||||
|
|
||||||
@Deprecated("Use GIT_REMOTE_URL instead")
|
@Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PORT = "git_remote_port"
|
||||||
const val GIT_REMOTE_PORT = "git_remote_port"
|
|
||||||
|
|
||||||
@Deprecated("Use GIT_REMOTE_URL instead")
|
@Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PROTOCOL = "git_remote_protocol"
|
||||||
const val GIT_REMOTE_PROTOCOL = "git_remote_protocol"
|
const val GIT_DELETE_REPO = "git_delete_repo"
|
||||||
const val GIT_DELETE_REPO = "git_delete_repo"
|
|
||||||
|
|
||||||
@Deprecated("Use GIT_REMOTE_URL instead")
|
@Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_SERVER = "git_remote_server"
|
||||||
const val GIT_REMOTE_SERVER = "git_remote_server"
|
const val GIT_REMOTE_URL = "git_remote_url"
|
||||||
const val GIT_REMOTE_URL = "git_remote_url"
|
|
||||||
|
|
||||||
@Deprecated("Use GIT_REMOTE_URL instead")
|
@Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_USERNAME = "git_remote_username"
|
||||||
const val GIT_REMOTE_USERNAME = "git_remote_username"
|
const val GIT_SERVER_INFO = "git_server_info"
|
||||||
const val GIT_SERVER_INFO = "git_server_info"
|
const val GIT_BRANCH_NAME = "git_branch"
|
||||||
const val GIT_BRANCH_NAME = "git_branch"
|
const val HTTPS_PASSWORD = "https_password"
|
||||||
const val HTTPS_PASSWORD = "https_password"
|
const val LENGTH = "length"
|
||||||
const val LENGTH = "length"
|
const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes"
|
||||||
const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes"
|
const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username"
|
||||||
const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username"
|
const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure"
|
||||||
const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure"
|
const val PREF_KEY_CUSTOM_DICT = "pref_key_custom_dict"
|
||||||
const val PREF_KEY_CUSTOM_DICT = "pref_key_custom_dict"
|
const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict"
|
||||||
const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict"
|
const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type"
|
||||||
const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type"
|
const val PREF_SELECT_EXTERNAL = "pref_select_external"
|
||||||
const val PREF_SELECT_EXTERNAL = "pref_select_external"
|
const val REPOSITORY_INITIALIZED = "repository_initialized"
|
||||||
const val REPOSITORY_INITIALIZED = "repository_initialized"
|
const val REPO_CHANGED = "repo_changed"
|
||||||
const val REPO_CHANGED = "repo_changed"
|
const val SEARCH_ON_START = "search_on_start"
|
||||||
const val SEARCH_ON_START = "search_on_start"
|
|
||||||
|
|
||||||
@Deprecated(
|
@Deprecated(
|
||||||
message = "Use SHOW_HIDDEN_CONTENTS instead",
|
message = "Use SHOW_HIDDEN_CONTENTS instead",
|
||||||
replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS")
|
replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS")
|
||||||
)
|
)
|
||||||
const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders"
|
const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders"
|
||||||
const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents"
|
const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents"
|
||||||
const val SORT_ORDER = "sort_order"
|
const val SORT_ORDER = "sort_order"
|
||||||
const val SHOW_PASSWORD = "show_password"
|
const val SHOW_PASSWORD = "show_password"
|
||||||
const val SSH_KEY = "ssh_key"
|
const val SSH_KEY = "ssh_key"
|
||||||
const val SSH_KEYGEN = "ssh_keygen"
|
const val SSH_KEYGEN = "ssh_keygen"
|
||||||
const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase"
|
const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase"
|
||||||
const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid"
|
const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid"
|
||||||
const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid"
|
const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid"
|
||||||
const val SSH_SEE_KEY = "ssh_see_key"
|
const val SSH_SEE_KEY = "ssh_see_key"
|
||||||
|
|
||||||
@Deprecated("To be used only in Migrations.kt")
|
@Deprecated("To be used only in Migrations.kt") const val USE_GENERATED_KEY = "use_generated_key"
|
||||||
const val USE_GENERATED_KEY = "use_generated_key"
|
|
||||||
|
|
||||||
const val PROXY_SETTINGS = "proxy_settings"
|
const val PROXY_SETTINGS = "proxy_settings"
|
||||||
const val PROXY_HOST = "proxy_host"
|
const val PROXY_HOST = "proxy_host"
|
||||||
const val PROXY_PORT = "proxy_port"
|
const val PROXY_PORT = "proxy_port"
|
||||||
const val PROXY_USERNAME = "proxy_username"
|
const val PROXY_USERNAME = "proxy_username"
|
||||||
const val PROXY_PASSWORD = "proxy_password"
|
const val PROXY_PASSWORD = "proxy_password"
|
||||||
|
|
||||||
const val REBASE_ON_PULL = "rebase_on_pull"
|
const val REBASE_ON_PULL = "rebase_on_pull"
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue