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="WRAP_WHEN_TYPING_REACHES_RIGHT_MARGIN" value="true" />
|
||||
<option name="FORMATTER_TAGS_ENABLED" value="true" />
|
||||
<option name="SOFT_MARGINS" value="100" />
|
||||
<option name="SOFT_MARGINS" value="120" />
|
||||
<option name="DO_NOT_FORMAT">
|
||||
<list>
|
||||
<fileSet type="namedScope" name="third_party" pattern="src[Android-Password-Store.app]:mozilla.components.lib.publicsuffixlist..*" />
|
||||
|
@ -161,7 +161,7 @@
|
|||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
<option name="RIGHT_MARGIN" value="100" />
|
||||
<option name="RIGHT_MARGIN" value="120" />
|
||||
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="0" />
|
||||
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
|
||||
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
|
||||
|
@ -183,4 +183,4 @@
|
|||
</indentOptions>
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
</component>
|
||||
|
|
|
@ -5,104 +5,100 @@
|
|||
import com.android.build.gradle.internal.api.BaseVariantOutputImpl
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
`versioning-plugin`
|
||||
`aps-plugin`
|
||||
`crowdin-plugin`
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
`versioning-plugin`
|
||||
`aps-plugin`
|
||||
`crowdin-plugin`
|
||||
}
|
||||
|
||||
configure<CrowdinExtension> {
|
||||
projectName = "android-password-store"
|
||||
}
|
||||
configure<CrowdinExtension> { projectName = "android-password-store" }
|
||||
|
||||
android {
|
||||
if (isSnapshot()) {
|
||||
applicationVariants.all {
|
||||
outputs.all {
|
||||
(this as BaseVariantOutputImpl).outputFileName = "aps-${flavorName}_$versionName.apk"
|
||||
}
|
||||
}
|
||||
if (isSnapshot()) {
|
||||
applicationVariants.all {
|
||||
outputs.all {
|
||||
(this as BaseVariantOutputImpl).outputFileName = "aps-${flavorName}_$versionName.apk"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "dev.msfjarvis.aps"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
defaultConfig {
|
||||
applicationId = "dev.msfjarvis.aps"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
isAbortOnError = true
|
||||
isCheckReleaseBuilds = false
|
||||
disable("MissingTranslation", "PluralsCandidate", "ImpliedQuantity")
|
||||
}
|
||||
lintOptions {
|
||||
isAbortOnError = true
|
||||
isCheckReleaseBuilds = false
|
||||
disable("MissingTranslation", "PluralsCandidate", "ImpliedQuantity")
|
||||
}
|
||||
|
||||
flavorDimensions("free")
|
||||
productFlavors {
|
||||
create("free") {
|
||||
}
|
||||
create("nonFree") {
|
||||
}
|
||||
}
|
||||
flavorDimensions("free")
|
||||
productFlavors {
|
||||
create("free") {}
|
||||
create("nonFree") {}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(Dependencies.AndroidX.annotation)
|
||||
implementation(project(":autofill-parser"))
|
||||
implementation(project(":openpgp-ktx"))
|
||||
implementation(Dependencies.AndroidX.activity_ktx)
|
||||
implementation(Dependencies.AndroidX.appcompat)
|
||||
implementation(Dependencies.AndroidX.autofill)
|
||||
implementation(Dependencies.AndroidX.biometric_ktx)
|
||||
implementation(Dependencies.AndroidX.constraint_layout)
|
||||
implementation(Dependencies.AndroidX.core_ktx)
|
||||
implementation(Dependencies.AndroidX.documentfile)
|
||||
implementation(Dependencies.AndroidX.fragment_ktx)
|
||||
implementation(Dependencies.AndroidX.lifecycle_common)
|
||||
implementation(Dependencies.AndroidX.lifecycle_livedata_ktx)
|
||||
implementation(Dependencies.AndroidX.lifecycle_viewmodel_ktx)
|
||||
implementation(Dependencies.AndroidX.material)
|
||||
implementation(Dependencies.AndroidX.preference)
|
||||
implementation(Dependencies.AndroidX.recycler_view)
|
||||
implementation(Dependencies.AndroidX.recycler_view_selection)
|
||||
implementation(Dependencies.AndroidX.security)
|
||||
implementation(Dependencies.AndroidX.swiperefreshlayout)
|
||||
compileOnly(Dependencies.AndroidX.annotation)
|
||||
implementation(project(":autofill-parser"))
|
||||
implementation(project(":openpgp-ktx"))
|
||||
implementation(Dependencies.AndroidX.activity_ktx)
|
||||
implementation(Dependencies.AndroidX.appcompat)
|
||||
implementation(Dependencies.AndroidX.autofill)
|
||||
implementation(Dependencies.AndroidX.biometric_ktx)
|
||||
implementation(Dependencies.AndroidX.constraint_layout)
|
||||
implementation(Dependencies.AndroidX.core_ktx)
|
||||
implementation(Dependencies.AndroidX.documentfile)
|
||||
implementation(Dependencies.AndroidX.fragment_ktx)
|
||||
implementation(Dependencies.AndroidX.lifecycle_common)
|
||||
implementation(Dependencies.AndroidX.lifecycle_livedata_ktx)
|
||||
implementation(Dependencies.AndroidX.lifecycle_viewmodel_ktx)
|
||||
implementation(Dependencies.AndroidX.material)
|
||||
implementation(Dependencies.AndroidX.preference)
|
||||
implementation(Dependencies.AndroidX.recycler_view)
|
||||
implementation(Dependencies.AndroidX.recycler_view_selection)
|
||||
implementation(Dependencies.AndroidX.security)
|
||||
implementation(Dependencies.AndroidX.swiperefreshlayout)
|
||||
|
||||
implementation(Dependencies.Kotlin.Coroutines.android)
|
||||
implementation(Dependencies.Kotlin.Coroutines.core)
|
||||
implementation(Dependencies.Kotlin.Coroutines.android)
|
||||
implementation(Dependencies.Kotlin.Coroutines.core)
|
||||
|
||||
implementation(Dependencies.FirstParty.zxing_android_embedded)
|
||||
implementation(Dependencies.FirstParty.zxing_android_embedded)
|
||||
|
||||
implementation(Dependencies.ThirdParty.bouncycastle)
|
||||
implementation(Dependencies.ThirdParty.commons_codec)
|
||||
implementation(Dependencies.ThirdParty.eddsa)
|
||||
implementation(Dependencies.ThirdParty.fastscroll)
|
||||
implementation(Dependencies.ThirdParty.jgit) {
|
||||
exclude(group = "org.apache.httpcomponents", module = "httpclient")
|
||||
}
|
||||
implementation(Dependencies.ThirdParty.kotlin_result)
|
||||
implementation(Dependencies.ThirdParty.modern_android_prefs)
|
||||
implementation(Dependencies.ThirdParty.plumber)
|
||||
implementation(Dependencies.ThirdParty.ssh_auth)
|
||||
implementation(Dependencies.ThirdParty.sshj)
|
||||
implementation(Dependencies.ThirdParty.timber)
|
||||
implementation(Dependencies.ThirdParty.timberkt)
|
||||
implementation(Dependencies.ThirdParty.bouncycastle)
|
||||
implementation(Dependencies.ThirdParty.commons_codec)
|
||||
implementation(Dependencies.ThirdParty.eddsa)
|
||||
implementation(Dependencies.ThirdParty.fastscroll)
|
||||
implementation(Dependencies.ThirdParty.jgit) {
|
||||
exclude(group = "org.apache.httpcomponents", module = "httpclient")
|
||||
}
|
||||
implementation(Dependencies.ThirdParty.kotlin_result)
|
||||
implementation(Dependencies.ThirdParty.modern_android_prefs)
|
||||
implementation(Dependencies.ThirdParty.plumber)
|
||||
implementation(Dependencies.ThirdParty.ssh_auth)
|
||||
implementation(Dependencies.ThirdParty.sshj)
|
||||
implementation(Dependencies.ThirdParty.timber)
|
||||
implementation(Dependencies.ThirdParty.timberkt)
|
||||
|
||||
if (isSnapshot()) {
|
||||
implementation(Dependencies.ThirdParty.leakcanary)
|
||||
implementation(Dependencies.ThirdParty.whatthestack)
|
||||
} else {
|
||||
debugImplementation(Dependencies.ThirdParty.leakcanary)
|
||||
debugImplementation(Dependencies.ThirdParty.whatthestack)
|
||||
}
|
||||
if (isSnapshot()) {
|
||||
implementation(Dependencies.ThirdParty.leakcanary)
|
||||
implementation(Dependencies.ThirdParty.whatthestack)
|
||||
} else {
|
||||
debugImplementation(Dependencies.ThirdParty.leakcanary)
|
||||
debugImplementation(Dependencies.ThirdParty.whatthestack)
|
||||
}
|
||||
|
||||
"nonFreeImplementation"(Dependencies.NonFree.google_play_auth_api_phone)
|
||||
"nonFreeImplementation"(Dependencies.NonFree.google_play_auth_api_phone)
|
||||
|
||||
// Testing-only dependencies
|
||||
androidTestImplementation(Dependencies.Testing.junit)
|
||||
androidTestImplementation(Dependencies.Testing.kotlin_test_junit)
|
||||
androidTestImplementation(Dependencies.Testing.AndroidX.runner)
|
||||
androidTestImplementation(Dependencies.Testing.AndroidX.rules)
|
||||
// Testing-only dependencies
|
||||
androidTestImplementation(Dependencies.Testing.junit)
|
||||
androidTestImplementation(Dependencies.Testing.kotlin_test_junit)
|
||||
androidTestImplementation(Dependencies.Testing.AndroidX.runner)
|
||||
androidTestImplementation(Dependencies.Testing.AndroidX.rules)
|
||||
|
||||
testImplementation(Dependencies.Testing.junit)
|
||||
testImplementation(Dependencies.Testing.kotlin_test_junit)
|
||||
testImplementation(Dependencies.Testing.junit)
|
||||
testImplementation(Dependencies.Testing.kotlin_test_junit)
|
||||
}
|
||||
|
|
|
@ -18,98 +18,105 @@ import org.junit.Test
|
|||
|
||||
class PasswordEntryAndroidTest {
|
||||
|
||||
private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder())
|
||||
private fun makeEntry(content: String) = PasswordEntry(content, UriTotpFinder())
|
||||
|
||||
@Test fun testGetPassword() {
|
||||
assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
|
||||
assertEquals("fooooo", makeEntry("fooooo\nbla").password)
|
||||
assertEquals("fooooo", makeEntry("fooooo\n").password)
|
||||
assertEquals("fooooo", makeEntry("fooooo").password)
|
||||
assertEquals("", makeEntry("\nblubb\n").password)
|
||||
assertEquals("", makeEntry("\nblubb").password)
|
||||
assertEquals("", makeEntry("\n").password)
|
||||
assertEquals("", makeEntry("").password)
|
||||
@Test
|
||||
fun testGetPassword() {
|
||||
assertEquals("fooooo", makeEntry("fooooo\nbla\n").password)
|
||||
assertEquals("fooooo", makeEntry("fooooo\nbla").password)
|
||||
assertEquals("fooooo", makeEntry("fooooo\n").password)
|
||||
assertEquals("fooooo", makeEntry("fooooo").password)
|
||||
assertEquals("", makeEntry("\nblubb\n").password)
|
||||
assertEquals("", makeEntry("\nblubb").password)
|
||||
assertEquals("", makeEntry("\n").password)
|
||||
assertEquals("", makeEntry("").password)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetExtraContent() {
|
||||
assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent)
|
||||
assertEquals("bla", makeEntry("fooooo\nbla").extraContent)
|
||||
assertEquals("", makeEntry("fooooo\n").extraContent)
|
||||
assertEquals("", makeEntry("fooooo").extraContent)
|
||||
assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent)
|
||||
assertEquals("blubb", makeEntry("\nblubb").extraContent)
|
||||
assertEquals("", makeEntry("\n").extraContent)
|
||||
assertEquals("", makeEntry("").extraContent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGetUsername() {
|
||||
for (field in PasswordEntry.USERNAME_FIELDS) {
|
||||
assertEquals("username", makeEntry("\n$field username").username)
|
||||
assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username)
|
||||
}
|
||||
assertEquals("username", makeEntry("secret\nextra\nlogin: username\ncontent\n").username)
|
||||
assertEquals("username", makeEntry("\nextra\nusername: username\ncontent\n").username)
|
||||
assertEquals("username", makeEntry("\nUSERNaMe: username\ncontent\n").username)
|
||||
assertEquals("username", makeEntry("\nlogin: username").username)
|
||||
assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username)
|
||||
assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username)
|
||||
assertEquals("username", makeEntry("\nLOGiN:username").username)
|
||||
assertNull(makeEntry("secret\nextra\ncontent\n").username)
|
||||
}
|
||||
|
||||
@Test fun testGetExtraContent() {
|
||||
assertEquals("bla\n", makeEntry("fooooo\nbla\n").extraContent)
|
||||
assertEquals("bla", makeEntry("fooooo\nbla").extraContent)
|
||||
assertEquals("", makeEntry("fooooo\n").extraContent)
|
||||
assertEquals("", makeEntry("fooooo").extraContent)
|
||||
assertEquals("blubb\n", makeEntry("\nblubb\n").extraContent)
|
||||
assertEquals("blubb", makeEntry("\nblubb").extraContent)
|
||||
assertEquals("", makeEntry("\n").extraContent)
|
||||
assertEquals("", makeEntry("").extraContent)
|
||||
}
|
||||
@Test
|
||||
fun testHasUsername() {
|
||||
assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername())
|
||||
assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername())
|
||||
assertFalse(makeEntry("secret\nlogin failed\n").hasUsername())
|
||||
assertFalse(makeEntry("\n").hasUsername())
|
||||
assertFalse(makeEntry("").hasUsername())
|
||||
}
|
||||
|
||||
@Test fun testGetUsername() {
|
||||
for (field in PasswordEntry.USERNAME_FIELDS) {
|
||||
assertEquals("username", makeEntry("\n$field username").username)
|
||||
assertEquals("username", makeEntry("\n${field.toUpperCase()} username").username)
|
||||
}
|
||||
assertEquals(
|
||||
"username",
|
||||
makeEntry("secret\nextra\nlogin: username\ncontent\n").username)
|
||||
assertEquals(
|
||||
"username",
|
||||
makeEntry("\nextra\nusername: username\ncontent\n").username)
|
||||
assertEquals(
|
||||
"username", makeEntry("\nUSERNaMe: username\ncontent\n").username)
|
||||
assertEquals("username", makeEntry("\nlogin: username").username)
|
||||
assertEquals("foo@example.com", makeEntry("\nemail: foo@example.com").username)
|
||||
assertEquals("username", makeEntry("\nidentity: username\nlogin: another_username").username)
|
||||
assertEquals("username", makeEntry("\nLOGiN:username").username)
|
||||
assertNull(makeEntry("secret\nextra\ncontent\n").username)
|
||||
}
|
||||
@Test
|
||||
fun testGeneratesOtpFromTotpUri() {
|
||||
val entry = makeEntry("secret\nextra\n$TOTP_URI")
|
||||
assertTrue(entry.hasTotp())
|
||||
val code =
|
||||
Otp.calculateCode(
|
||||
entry.totpSecret!!,
|
||||
// The hardcoded date value allows this test to stay reproducible.
|
||||
Date(8640000).time / (1000 * entry.totpPeriod),
|
||||
entry.totpAlgorithm,
|
||||
entry.digits
|
||||
)
|
||||
.get()
|
||||
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||
assertEquals(entry.digits.toInt(), code.length)
|
||||
assertEquals("545293", code)
|
||||
}
|
||||
|
||||
@Test fun testHasUsername() {
|
||||
assertTrue(makeEntry("secret\nextra\nlogin: username\ncontent\n").hasUsername())
|
||||
assertFalse(makeEntry("secret\nextra\ncontent\n").hasUsername())
|
||||
assertFalse(makeEntry("secret\nlogin failed\n").hasUsername())
|
||||
assertFalse(makeEntry("\n").hasUsername())
|
||||
assertFalse(makeEntry("").hasUsername())
|
||||
}
|
||||
@Test
|
||||
fun testGeneratesOtpWithOnlyUriInFile() {
|
||||
val entry = makeEntry(TOTP_URI)
|
||||
assertTrue(entry.password.isEmpty())
|
||||
assertTrue(entry.hasTotp())
|
||||
val code =
|
||||
Otp.calculateCode(
|
||||
entry.totpSecret!!,
|
||||
// The hardcoded date value allows this test to stay reproducible.
|
||||
Date(8640000).time / (1000 * entry.totpPeriod),
|
||||
entry.totpAlgorithm,
|
||||
entry.digits
|
||||
)
|
||||
.get()
|
||||
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||
assertEquals(entry.digits.toInt(), code.length)
|
||||
assertEquals("545293", code)
|
||||
}
|
||||
|
||||
@Test fun testGeneratesOtpFromTotpUri() {
|
||||
val entry = makeEntry("secret\nextra\n$TOTP_URI")
|
||||
assertTrue(entry.hasTotp())
|
||||
val code = Otp.calculateCode(
|
||||
entry.totpSecret!!,
|
||||
// The hardcoded date value allows this test to stay reproducible.
|
||||
Date(8640000).time / (1000 * entry.totpPeriod),
|
||||
entry.totpAlgorithm,
|
||||
entry.digits
|
||||
).get()
|
||||
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||
assertEquals(entry.digits.toInt(), code.length)
|
||||
assertEquals("545293", code)
|
||||
}
|
||||
@Test
|
||||
fun testOnlyLooksForUriInFirstLine() {
|
||||
val entry = makeEntry("id:\n$TOTP_URI")
|
||||
assertTrue(entry.password.isNotEmpty())
|
||||
assertTrue(entry.hasTotp())
|
||||
assertFalse(entry.hasUsername())
|
||||
}
|
||||
|
||||
@Test fun testGeneratesOtpWithOnlyUriInFile() {
|
||||
val entry = makeEntry(TOTP_URI)
|
||||
assertTrue(entry.password.isEmpty())
|
||||
assertTrue(entry.hasTotp())
|
||||
val code = Otp.calculateCode(
|
||||
entry.totpSecret!!,
|
||||
// The hardcoded date value allows this test to stay reproducible.
|
||||
Date(8640000).time / (1000 * entry.totpPeriod),
|
||||
entry.totpAlgorithm,
|
||||
entry.digits
|
||||
).get()
|
||||
assertNotNull(code) { "Generated OTP cannot be null" }
|
||||
assertEquals(entry.digits.toInt(), code.length)
|
||||
assertEquals("545293", code)
|
||||
}
|
||||
companion object {
|
||||
|
||||
@Test fun testOnlyLooksForUriInFirstLine() {
|
||||
val entry = makeEntry("id:\n$TOTP_URI")
|
||||
assertTrue(entry.password.isNotEmpty())
|
||||
assertTrue(entry.hasTotp())
|
||||
assertFalse(entry.hasUsername())
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
|
||||
}
|
||||
const val TOTP_URI =
|
||||
"otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,102 +19,103 @@ import org.junit.Test
|
|||
|
||||
class MigrationsTest {
|
||||
|
||||
private fun checkOldKeysAreRemoved(context: Context) = with(context.sharedPrefs) {
|
||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT))
|
||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME))
|
||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER))
|
||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION))
|
||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
|
||||
private fun checkOldKeysAreRemoved(context: Context) =
|
||||
with(context.sharedPrefs) {
|
||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT))
|
||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME))
|
||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER))
|
||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_LOCATION))
|
||||
assertNull(getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifySshWithCustomPortMigration() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit {
|
||||
clear()
|
||||
putString(PreferenceKeys.GIT_REMOTE_PORT, "2200")
|
||||
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
||||
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
|
||||
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
|
||||
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
|
||||
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.Password.pref)
|
||||
}
|
||||
runMigrations(context)
|
||||
checkOldKeysAreRemoved(context)
|
||||
assertEquals(
|
||||
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
||||
"ssh://msfjarvis@192.168.0.102:2200/mnt/disk3/pass-repo"
|
||||
)
|
||||
@Test
|
||||
fun verifySshWithCustomPortMigration() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit {
|
||||
clear()
|
||||
putString(PreferenceKeys.GIT_REMOTE_PORT, "2200")
|
||||
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
||||
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
|
||||
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
|
||||
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
|
||||
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.Password.pref)
|
||||
}
|
||||
runMigrations(context)
|
||||
checkOldKeysAreRemoved(context)
|
||||
assertEquals(
|
||||
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
||||
"ssh://msfjarvis@192.168.0.102:2200/mnt/disk3/pass-repo"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifySshWithDefaultPortMigration() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit {
|
||||
clear()
|
||||
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
||||
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
|
||||
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
|
||||
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
|
||||
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.SshKey.pref)
|
||||
}
|
||||
runMigrations(context)
|
||||
checkOldKeysAreRemoved(context)
|
||||
assertEquals(
|
||||
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
||||
"msfjarvis@192.168.0.102:/mnt/disk3/pass-repo"
|
||||
)
|
||||
@Test
|
||||
fun verifySshWithDefaultPortMigration() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit {
|
||||
clear()
|
||||
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
||||
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "/mnt/disk3/pass-repo")
|
||||
putString(PreferenceKeys.GIT_REMOTE_SERVER, "192.168.0.102")
|
||||
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Ssh.pref)
|
||||
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.SshKey.pref)
|
||||
}
|
||||
runMigrations(context)
|
||||
checkOldKeysAreRemoved(context)
|
||||
assertEquals(
|
||||
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
||||
"msfjarvis@192.168.0.102:/mnt/disk3/pass-repo"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyHttpsWithGitHubMigration() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit {
|
||||
clear()
|
||||
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
||||
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "Android-Password-Store/pass-test")
|
||||
putString(PreferenceKeys.GIT_REMOTE_SERVER, "github.com")
|
||||
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Https.pref)
|
||||
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.None.pref)
|
||||
}
|
||||
runMigrations(context)
|
||||
checkOldKeysAreRemoved(context)
|
||||
assertEquals(
|
||||
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
||||
"https://github.com/Android-Password-Store/pass-test"
|
||||
)
|
||||
@Test
|
||||
fun verifyHttpsWithGitHubMigration() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit {
|
||||
clear()
|
||||
putString(PreferenceKeys.GIT_REMOTE_USERNAME, "msfjarvis")
|
||||
putString(PreferenceKeys.GIT_REMOTE_LOCATION, "Android-Password-Store/pass-test")
|
||||
putString(PreferenceKeys.GIT_REMOTE_SERVER, "github.com")
|
||||
putString(PreferenceKeys.GIT_REMOTE_PROTOCOL, Protocol.Https.pref)
|
||||
putString(PreferenceKeys.GIT_REMOTE_AUTH, AuthMode.None.pref)
|
||||
}
|
||||
runMigrations(context)
|
||||
checkOldKeysAreRemoved(context)
|
||||
assertEquals(
|
||||
context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_URL),
|
||||
"https://github.com/Android-Password-Store/pass-test"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyHiddenFoldersMigrationIfDisabled() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit { clear() }
|
||||
runMigrations(context)
|
||||
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true))
|
||||
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
|
||||
}
|
||||
@Test
|
||||
fun verifyHiddenFoldersMigrationIfDisabled() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit { clear() }
|
||||
runMigrations(context)
|
||||
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true))
|
||||
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyHiddenFoldersMigrationIfEnabled() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit {
|
||||
clear()
|
||||
putBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true)
|
||||
}
|
||||
runMigrations(context)
|
||||
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false))
|
||||
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
|
||||
@Test
|
||||
fun verifyHiddenFoldersMigrationIfEnabled() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit {
|
||||
clear()
|
||||
putBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, true)
|
||||
}
|
||||
runMigrations(context)
|
||||
assertEquals(false, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false))
|
||||
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifyClearClipboardHistoryMigration() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit {
|
||||
clear()
|
||||
putBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, true)
|
||||
}
|
||||
runMigrations(context)
|
||||
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false))
|
||||
assertFalse(context.sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X))
|
||||
@Test
|
||||
fun verifyClearClipboardHistoryMigration() {
|
||||
val context = Application.instance.applicationContext
|
||||
context.sharedPrefs.edit {
|
||||
clear()
|
||||
putBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, true)
|
||||
}
|
||||
runMigrations(context)
|
||||
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false))
|
||||
assertFalse(context.sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,36 +10,40 @@ import org.junit.Test
|
|||
|
||||
class UriTotpFinderTest {
|
||||
|
||||
private val totpFinder = UriTotpFinder()
|
||||
private val totpFinder = UriTotpFinder()
|
||||
|
||||
@Test
|
||||
fun findSecret() {
|
||||
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI))
|
||||
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"))
|
||||
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT))
|
||||
}
|
||||
@Test
|
||||
fun findSecret() {
|
||||
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI))
|
||||
assertEquals(
|
||||
"HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ",
|
||||
totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")
|
||||
)
|
||||
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findDigits() {
|
||||
assertEquals("12", totpFinder.findDigits(TOTP_URI))
|
||||
assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT))
|
||||
}
|
||||
@Test
|
||||
fun findDigits() {
|
||||
assertEquals("12", totpFinder.findDigits(TOTP_URI))
|
||||
assertEquals("12", totpFinder.findDigits(PASS_FILE_CONTENT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findPeriod() {
|
||||
assertEquals(25, totpFinder.findPeriod(TOTP_URI))
|
||||
assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT))
|
||||
}
|
||||
@Test
|
||||
fun findPeriod() {
|
||||
assertEquals(25, totpFinder.findPeriod(TOTP_URI))
|
||||
assertEquals(25, totpFinder.findPeriod(PASS_FILE_CONTENT))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun findAlgorithm() {
|
||||
assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI))
|
||||
assertEquals("SHA256", totpFinder.findAlgorithm(PASS_FILE_CONTENT))
|
||||
}
|
||||
@Test
|
||||
fun findAlgorithm() {
|
||||
assertEquals("SHA256", totpFinder.findAlgorithm(TOTP_URI))
|
||||
assertEquals("SHA256", totpFinder.findAlgorithm(PASS_FILE_CONTENT))
|
||||
}
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25"
|
||||
const val PASS_FILE_CONTENT = "password\n$TOTP_URI"
|
||||
}
|
||||
const val TOTP_URI =
|
||||
"otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25"
|
||||
const val PASS_FILE_CONTENT = "password\n$TOTP_URI"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,40 +10,46 @@ import kotlin.test.assertTrue
|
|||
import org.junit.Test
|
||||
|
||||
private infix fun String.matchedForDomain(domain: String) =
|
||||
SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true
|
||||
SearchableRepositoryViewModel.generateStrictDomainRegex(domain)?.containsMatchIn(this) == true
|
||||
|
||||
class StrictDomainRegexTest {
|
||||
|
||||
@Test fun acceptsLiteralDomain() {
|
||||
assertTrue("work/example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||
assertTrue("example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||
assertTrue("example.org.gpg" matchedForDomain "example.org")
|
||||
}
|
||||
@Test
|
||||
fun acceptsLiteralDomain() {
|
||||
assertTrue("work/example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||
assertTrue("example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||
assertTrue("example.org.gpg" matchedForDomain "example.org")
|
||||
}
|
||||
|
||||
@Test fun acceptsSubdomains() {
|
||||
assertTrue("work/www.example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||
assertTrue("www2.example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||
assertTrue("www.login.example.org.gpg" matchedForDomain "example.org")
|
||||
}
|
||||
@Test
|
||||
fun acceptsSubdomains() {
|
||||
assertTrue("work/www.example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||
assertTrue("www2.example.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||
assertTrue("www.login.example.org.gpg" matchedForDomain "example.org")
|
||||
}
|
||||
|
||||
@Test fun rejectsPhishingAttempts() {
|
||||
assertFalse("example.org.gpg" matchedForDomain "xample.org")
|
||||
assertFalse("login.example.org.gpg" matchedForDomain "xample.org")
|
||||
assertFalse("example.org/john.doe@exmple.org.gpg" matchedForDomain "xample.org")
|
||||
assertFalse("example.org.gpg" matchedForDomain "e/xample.org")
|
||||
}
|
||||
@Test
|
||||
fun rejectsPhishingAttempts() {
|
||||
assertFalse("example.org.gpg" matchedForDomain "xample.org")
|
||||
assertFalse("login.example.org.gpg" matchedForDomain "xample.org")
|
||||
assertFalse("example.org/john.doe@exmple.org.gpg" matchedForDomain "xample.org")
|
||||
assertFalse("example.org.gpg" matchedForDomain "e/xample.org")
|
||||
}
|
||||
|
||||
@Test fun rejectNonGpgComponentMatches() {
|
||||
assertFalse("work/example.org" matchedForDomain "example.org")
|
||||
}
|
||||
@Test
|
||||
fun rejectNonGpgComponentMatches() {
|
||||
assertFalse("work/example.org" matchedForDomain "example.org")
|
||||
}
|
||||
|
||||
@Test fun rejectsEmailAddresses() {
|
||||
assertFalse("work/notexample.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||
assertFalse("work/notexample.org/john.doe@www.example.org.gpg" matchedForDomain "example.org")
|
||||
assertFalse("work/john.doe@www.example.org/foo.org" matchedForDomain "example.org")
|
||||
}
|
||||
@Test
|
||||
fun rejectsEmailAddresses() {
|
||||
assertFalse("work/notexample.org/john.doe@example.org.gpg" matchedForDomain "example.org")
|
||||
assertFalse("work/notexample.org/john.doe@www.example.org.gpg" matchedForDomain "example.org")
|
||||
assertFalse("work/john.doe@www.example.org/foo.org" matchedForDomain "example.org")
|
||||
}
|
||||
|
||||
@Test fun rejectsPathSeparators() {
|
||||
assertNull(SearchableRepositoryViewModel.generateStrictDomainRegex("ex/ample.org"))
|
||||
}
|
||||
@Test
|
||||
fun rejectsPathSeparators() {
|
||||
assertNull(SearchableRepositoryViewModel.generateStrictDomainRegex("ex/ample.org"))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,14 +14,14 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
@Suppress("UNUSED_PARAMETER")
|
||||
class AutofillSmsActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
fun shouldOfferFillFromSms(context: Context): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender {
|
||||
throw NotImplementedError("Filling OTPs from SMS requires non-free dependencies")
|
||||
}
|
||||
fun shouldOfferFillFromSms(context: Context): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender {
|
||||
throw NotImplementedError("Filling OTPs from SMS requires non-free dependencies")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,45 +22,45 @@ import dev.msfjarvis.aps.util.settings.runMigrations
|
|||
@Suppress("Unused")
|
||||
class Application : android.app.Application(), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
|
||||
private val prefs by lazy { sharedPrefs }
|
||||
private val prefs by lazy { sharedPrefs }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
if (BuildConfig.ENABLE_DEBUG_FEATURES ||
|
||||
prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) {
|
||||
plant(DebugTree())
|
||||
}
|
||||
prefs.registerOnSharedPreferenceChangeListener(this)
|
||||
setNightMode()
|
||||
setUpBouncyCastleForSshj()
|
||||
runMigrations(applicationContext)
|
||||
ProxyUtils.setDefaultProxy()
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
if (BuildConfig.ENABLE_DEBUG_FEATURES || prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) {
|
||||
plant(DebugTree())
|
||||
}
|
||||
prefs.registerOnSharedPreferenceChangeListener(this)
|
||||
setNightMode()
|
||||
setUpBouncyCastleForSshj()
|
||||
runMigrations(applicationContext)
|
||||
ProxyUtils.setDefaultProxy()
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(this)
|
||||
super.onTerminate()
|
||||
override fun onTerminate() {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(this)
|
||||
super.onTerminate()
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) {
|
||||
if (key == PreferenceKeys.APP_THEME) {
|
||||
setNightMode()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String) {
|
||||
if (key == PreferenceKeys.APP_THEME) {
|
||||
setNightMode()
|
||||
}
|
||||
}
|
||||
private fun setNightMode() {
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) {
|
||||
"light" -> MODE_NIGHT_NO
|
||||
"dark" -> MODE_NIGHT_YES
|
||||
"follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM
|
||||
else -> MODE_NIGHT_AUTO_BATTERY
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setNightMode() {
|
||||
AppCompatDelegate.setDefaultNightMode(when (prefs.getString(PreferenceKeys.APP_THEME)
|
||||
?: getString(R.string.app_theme_def)) {
|
||||
"light" -> MODE_NIGHT_NO
|
||||
"dark" -> MODE_NIGHT_YES
|
||||
"follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM
|
||||
else -> MODE_NIGHT_AUTO_BATTERY
|
||||
})
|
||||
}
|
||||
companion object {
|
||||
|
||||
companion object {
|
||||
|
||||
lateinit var instance: Application
|
||||
}
|
||||
lateinit var instance: Application
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,27 +6,30 @@
|
|||
package dev.msfjarvis.aps.data.password
|
||||
|
||||
class FieldItem(val key: String, val value: String, val action: ActionType) {
|
||||
enum class ActionType {
|
||||
COPY, HIDE
|
||||
enum class ActionType {
|
||||
COPY,
|
||||
HIDE
|
||||
}
|
||||
|
||||
enum class ItemType(val type: String) {
|
||||
USERNAME("Username"),
|
||||
PASSWORD("Password"),
|
||||
OTP("OTP")
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
// Extra helper methods
|
||||
fun createOtpField(otp: String): FieldItem {
|
||||
return FieldItem(ItemType.OTP.type, otp, ActionType.COPY)
|
||||
}
|
||||
|
||||
enum class ItemType(val type: String) {
|
||||
USERNAME("Username"), PASSWORD("Password"), OTP("OTP")
|
||||
fun createPasswordField(password: String): FieldItem {
|
||||
return FieldItem(ItemType.PASSWORD.type, password, ActionType.HIDE)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
// Extra helper methods
|
||||
fun createOtpField(otp: String): FieldItem {
|
||||
return FieldItem(ItemType.OTP.type, otp, ActionType.COPY)
|
||||
}
|
||||
|
||||
fun createPasswordField(password: String): FieldItem {
|
||||
return FieldItem(ItemType.PASSWORD.type, password, ActionType.HIDE)
|
||||
}
|
||||
|
||||
fun createUsernameField(username: String): FieldItem {
|
||||
return FieldItem(ItemType.USERNAME.type, username, ActionType.COPY)
|
||||
}
|
||||
fun createUsernameField(username: String): FieldItem {
|
||||
return FieldItem(ItemType.USERNAME.type, username, ActionType.COPY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,178 +18,178 @@ import java.util.Date
|
|||
*/
|
||||
class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTotpFinder()) {
|
||||
|
||||
val password: String
|
||||
val username: String?
|
||||
val password: String
|
||||
val username: String?
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val digits: String
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpSecret: String?
|
||||
val totpPeriod: Long
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpAlgorithm: String
|
||||
val extraContent: String
|
||||
val extraContentWithoutAuthData: String
|
||||
val extraContentMap: Map<String, String>
|
||||
|
||||
constructor(os: ByteArrayOutputStream) : this(os.toString(Charsets.UTF_8.name()), UriTotpFinder())
|
||||
|
||||
init {
|
||||
val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
|
||||
password = foundPassword
|
||||
extraContent = passContent.joinToString("\n")
|
||||
extraContentWithoutAuthData = generateExtraContentWithoutAuthData()
|
||||
extraContentMap = generateExtraContentPairs()
|
||||
username = findUsername()
|
||||
digits = findOtpDigits(content)
|
||||
totpSecret = findTotpSecret(content)
|
||||
totpPeriod = findTotpPeriod(content)
|
||||
totpAlgorithm = findTotpAlgorithm(content)
|
||||
}
|
||||
|
||||
fun hasExtraContent(): Boolean {
|
||||
return extraContent.isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasExtraContentWithoutAuthData(): Boolean {
|
||||
return extraContentWithoutAuthData.isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasTotp(): Boolean {
|
||||
return totpSecret != null
|
||||
}
|
||||
|
||||
fun hasUsername(): Boolean {
|
||||
return username != null
|
||||
}
|
||||
|
||||
fun calculateTotpCode(): String? {
|
||||
if (totpSecret == null) return null
|
||||
return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get()
|
||||
}
|
||||
|
||||
private fun generateExtraContentWithoutAuthData(): String {
|
||||
var foundUsername = false
|
||||
return extraContent
|
||||
.lineSequence()
|
||||
.filter { line ->
|
||||
return@filter when {
|
||||
USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> {
|
||||
foundUsername = true
|
||||
false
|
||||
}
|
||||
line.startsWith("otpauth://", ignoreCase = true) || line.startsWith("totp:", ignoreCase = true) -> {
|
||||
false
|
||||
}
|
||||
else -> {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
private fun generateExtraContentPairs(): Map<String, String> {
|
||||
fun MutableMap<String, String>.putOrAppend(key: String, value: String) {
|
||||
if (value.isEmpty()) return
|
||||
val existing = this[key]
|
||||
this[key] =
|
||||
if (existing == null) {
|
||||
value
|
||||
} else {
|
||||
"$existing\n$value"
|
||||
}
|
||||
}
|
||||
|
||||
val items = mutableMapOf<String, String>()
|
||||
// Take extraContentWithoutAuthData and onEach line perform the following tasks
|
||||
extraContentWithoutAuthData.lines().forEach { line ->
|
||||
// Split the line on ':' and save all the parts into an array
|
||||
// "ABC : DEF:GHI" --> ["ABC", "DEF", "GHI"]
|
||||
val splitArray = line.split(":")
|
||||
// Take the first element of the array. This will be the key for the key-value pair.
|
||||
// ["ABC ", " DEF", "GHI"] -> key = "ABC"
|
||||
val key = splitArray.first().trimEnd()
|
||||
// Remove the first element from the array and join the rest of the string again with
|
||||
// ':' as separator.
|
||||
// ["ABC ", " DEF", "GHI"] -> value = "DEF:GHI"
|
||||
val value = splitArray.drop(1).joinToString(":").trimStart()
|
||||
|
||||
if (key.isNotEmpty() && value.isNotEmpty()) {
|
||||
// If both key and value are not empty, we can form a pair with this so add it to
|
||||
// the map.
|
||||
// key = "ABC", value = "DEF:GHI"
|
||||
items[key] = value
|
||||
} else {
|
||||
// If either key or value is empty, we were not able to form proper key-value pair.
|
||||
// So append the original line into an "EXTRA CONTENT" map entry
|
||||
items.putOrAppend(EXTRA_CONTENT, line)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private fun findUsername(): String? {
|
||||
extraContent.splitToSequence("\n").forEach { line ->
|
||||
for (prefix in USERNAME_FIELDS) {
|
||||
if (line.startsWith(prefix, ignoreCase = true)) return line.substring(prefix.length).trimStart()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun findAndStripPassword(passContent: List<String>): Pair<String, List<String>> {
|
||||
if (UriTotpFinder.TOTP_FIELDS.any { passContent[0].startsWith(it) }) return Pair("", passContent)
|
||||
for (line in passContent) {
|
||||
for (prefix in PASSWORD_FIELDS) {
|
||||
if (line.startsWith(prefix, ignoreCase = true)) {
|
||||
return Pair(line.substring(prefix.length).trimStart(), passContent.minus(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
return Pair(passContent[0], passContent.minus(passContent[0]))
|
||||
}
|
||||
|
||||
private fun findTotpSecret(decryptedContent: String): String? {
|
||||
return totpFinder.findSecret(decryptedContent)
|
||||
}
|
||||
|
||||
private fun findOtpDigits(decryptedContent: String): String {
|
||||
return totpFinder.findDigits(decryptedContent)
|
||||
}
|
||||
|
||||
private fun findTotpPeriod(decryptedContent: String): Long {
|
||||
return totpFinder.findPeriod(decryptedContent)
|
||||
}
|
||||
|
||||
private fun findTotpAlgorithm(decryptedContent: String): String {
|
||||
return totpFinder.findAlgorithm(decryptedContent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_CONTENT = "Extra Content"
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
val digits: String
|
||||
val USERNAME_FIELDS =
|
||||
arrayOf(
|
||||
"login:",
|
||||
"username:",
|
||||
"user:",
|
||||
"account:",
|
||||
"email:",
|
||||
"name:",
|
||||
"handle:",
|
||||
"id:",
|
||||
"identity:",
|
||||
)
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
val totpSecret: String?
|
||||
val totpPeriod: Long
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
val totpAlgorithm: String
|
||||
val extraContent: String
|
||||
val extraContentWithoutAuthData: String
|
||||
val extraContentMap: Map<String, String>
|
||||
|
||||
constructor(os: ByteArrayOutputStream) : this(os.toString(Charsets.UTF_8.name()), UriTotpFinder())
|
||||
|
||||
init {
|
||||
val (foundPassword, passContent) = findAndStripPassword(content.split("\n".toRegex()))
|
||||
password = foundPassword
|
||||
extraContent = passContent.joinToString("\n")
|
||||
extraContentWithoutAuthData = generateExtraContentWithoutAuthData()
|
||||
extraContentMap = generateExtraContentPairs()
|
||||
username = findUsername()
|
||||
digits = findOtpDigits(content)
|
||||
totpSecret = findTotpSecret(content)
|
||||
totpPeriod = findTotpPeriod(content)
|
||||
totpAlgorithm = findTotpAlgorithm(content)
|
||||
}
|
||||
|
||||
fun hasExtraContent(): Boolean {
|
||||
return extraContent.isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasExtraContentWithoutAuthData(): Boolean {
|
||||
return extraContentWithoutAuthData.isNotEmpty()
|
||||
}
|
||||
|
||||
fun hasTotp(): Boolean {
|
||||
return totpSecret != null
|
||||
}
|
||||
|
||||
fun hasUsername(): Boolean {
|
||||
return username != null
|
||||
}
|
||||
|
||||
fun calculateTotpCode(): String? {
|
||||
if (totpSecret == null)
|
||||
return null
|
||||
return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get()
|
||||
}
|
||||
|
||||
private fun generateExtraContentWithoutAuthData(): String {
|
||||
var foundUsername = false
|
||||
return extraContent
|
||||
.lineSequence()
|
||||
.filter { line ->
|
||||
return@filter when {
|
||||
USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> {
|
||||
foundUsername = true
|
||||
false
|
||||
}
|
||||
line.startsWith("otpauth://", ignoreCase = true) ||
|
||||
line.startsWith("totp:", ignoreCase = true) -> {
|
||||
false
|
||||
}
|
||||
else -> {
|
||||
true
|
||||
}
|
||||
}
|
||||
}.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
private fun generateExtraContentPairs(): Map<String, String> {
|
||||
fun MutableMap<String, String>.putOrAppend(key: String, value: String) {
|
||||
if (value.isEmpty()) return
|
||||
val existing = this[key]
|
||||
this[key] = if (existing == null) {
|
||||
value
|
||||
} else {
|
||||
"$existing\n$value"
|
||||
}
|
||||
}
|
||||
|
||||
val items = mutableMapOf<String, String>()
|
||||
// Take extraContentWithoutAuthData and onEach line perform the following tasks
|
||||
extraContentWithoutAuthData.lines().forEach { line ->
|
||||
// Split the line on ':' and save all the parts into an array
|
||||
// "ABC : DEF:GHI" --> ["ABC", "DEF", "GHI"]
|
||||
val splitArray = line.split(":")
|
||||
// Take the first element of the array. This will be the key for the key-value pair.
|
||||
// ["ABC ", " DEF", "GHI"] -> key = "ABC"
|
||||
val key = splitArray.first().trimEnd()
|
||||
// Remove the first element from the array and join the rest of the string again with ':' as separator.
|
||||
// ["ABC ", " DEF", "GHI"] -> value = "DEF:GHI"
|
||||
val value = splitArray.drop(1).joinToString(":").trimStart()
|
||||
|
||||
if (key.isNotEmpty() && value.isNotEmpty()) {
|
||||
// If both key and value are not empty, we can form a pair with this so add it to the map.
|
||||
// key = "ABC", value = "DEF:GHI"
|
||||
items[key] = value
|
||||
} else {
|
||||
// If either key or value is empty, we were not able to form proper key-value pair.
|
||||
// So append the original line into an "EXTRA CONTENT" map entry
|
||||
items.putOrAppend(EXTRA_CONTENT, line)
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private fun findUsername(): String? {
|
||||
extraContent.splitToSequence("\n").forEach { line ->
|
||||
for (prefix in USERNAME_FIELDS) {
|
||||
if (line.startsWith(prefix, ignoreCase = true))
|
||||
return line.substring(prefix.length).trimStart()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun findAndStripPassword(passContent: List<String>): Pair<String, List<String>> {
|
||||
if (UriTotpFinder.TOTP_FIELDS.any { passContent[0].startsWith(it) }) return Pair("", passContent)
|
||||
for (line in passContent) {
|
||||
for (prefix in PASSWORD_FIELDS) {
|
||||
if (line.startsWith(prefix, ignoreCase = true)) {
|
||||
return Pair(line.substring(prefix.length).trimStart(), passContent.minus(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
return Pair(passContent[0], passContent.minus(passContent[0]))
|
||||
}
|
||||
|
||||
private fun findTotpSecret(decryptedContent: String): String? {
|
||||
return totpFinder.findSecret(decryptedContent)
|
||||
}
|
||||
|
||||
private fun findOtpDigits(decryptedContent: String): String {
|
||||
return totpFinder.findDigits(decryptedContent)
|
||||
}
|
||||
|
||||
private fun findTotpPeriod(decryptedContent: String): Long {
|
||||
return totpFinder.findPeriod(decryptedContent)
|
||||
}
|
||||
|
||||
private fun findTotpAlgorithm(decryptedContent: String): String {
|
||||
return totpFinder.findAlgorithm(decryptedContent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_CONTENT = "Extra Content"
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
val USERNAME_FIELDS = arrayOf(
|
||||
"login:",
|
||||
"username:",
|
||||
"user:",
|
||||
"account:",
|
||||
"email:",
|
||||
"name:",
|
||||
"handle:",
|
||||
"id:",
|
||||
"identity:",
|
||||
)
|
||||
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
val PASSWORD_FIELDS = arrayOf(
|
||||
"password:",
|
||||
"secret:",
|
||||
"pass:",
|
||||
)
|
||||
}
|
||||
val PASSWORD_FIELDS =
|
||||
arrayOf(
|
||||
"password:",
|
||||
"secret:",
|
||||
"pass:",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,79 +8,56 @@ import dev.msfjarvis.aps.ui.crypto.BasePgpActivity
|
|||
import java.io.File
|
||||
|
||||
data class PasswordItem(
|
||||
val name: String,
|
||||
val parent: PasswordItem? = null,
|
||||
val type: Char,
|
||||
val file: File,
|
||||
val rootDir: File
|
||||
val name: String,
|
||||
val parent: PasswordItem? = null,
|
||||
val type: Char,
|
||||
val file: File,
|
||||
val rootDir: File
|
||||
) : Comparable<PasswordItem> {
|
||||
|
||||
val fullPathToParent = file.absolutePath
|
||||
.replace(rootDir.absolutePath, "")
|
||||
.replace(file.name, "")
|
||||
val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "")
|
||||
|
||||
val longName = BasePgpActivity.getLongName(
|
||||
fullPathToParent,
|
||||
rootDir.absolutePath,
|
||||
toString())
|
||||
val longName = BasePgpActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString())
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return (other is PasswordItem) && (other.file == file)
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return (other is PasswordItem) && (other.file == file)
|
||||
}
|
||||
|
||||
override fun compareTo(other: PasswordItem): Int {
|
||||
return (type + name).compareTo(other.type + other.name, ignoreCase = true)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return name.replace("\\.gpg$".toRegex(), "")
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TYPE_CATEGORY = 'c'
|
||||
const val TYPE_PASSWORD = 'p'
|
||||
|
||||
@JvmStatic
|
||||
fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
|
||||
return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
|
||||
}
|
||||
|
||||
override fun compareTo(other: PasswordItem): Int {
|
||||
return (type + name).compareTo(other.type + other.name, ignoreCase = true)
|
||||
@JvmStatic
|
||||
fun newCategory(name: String, file: File, rootDir: File): PasswordItem {
|
||||
return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return name.replace("\\.gpg$".toRegex(), "")
|
||||
@JvmStatic
|
||||
fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
|
||||
return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TYPE_CATEGORY = 'c'
|
||||
const val TYPE_PASSWORD = 'p'
|
||||
|
||||
@JvmStatic
|
||||
fun newCategory(
|
||||
name: String,
|
||||
file: File,
|
||||
parent: PasswordItem,
|
||||
rootDir: File
|
||||
): PasswordItem {
|
||||
return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun newCategory(
|
||||
name: String,
|
||||
file: File,
|
||||
rootDir: File
|
||||
): PasswordItem {
|
||||
return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun newPassword(
|
||||
name: String,
|
||||
file: File,
|
||||
parent: PasswordItem,
|
||||
rootDir: File
|
||||
): PasswordItem {
|
||||
return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun newPassword(
|
||||
name: String,
|
||||
file: File,
|
||||
rootDir: File
|
||||
): PasswordItem {
|
||||
return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
|
||||
}
|
||||
@JvmStatic
|
||||
fun newPassword(name: String, file: File, rootDir: File): PasswordItem {
|
||||
return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,213 +31,211 @@ import org.eclipse.jgit.util.FS_POSIX_Java6
|
|||
|
||||
object PasswordRepository {
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private class FS_POSIX_Java6_with_optional_symlinks : FS_POSIX_Java6() {
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private class FS_POSIX_Java6_with_optional_symlinks : FS_POSIX_Java6() {
|
||||
|
||||
override fun supportsSymlinks() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
override fun supportsSymlinks() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
|
||||
override fun isSymLink(file: File) = Files.isSymbolicLink(file.toPath())
|
||||
override fun isSymLink(file: File) = Files.isSymbolicLink(file.toPath())
|
||||
|
||||
override fun readSymLink(file: File) = Files.readSymbolicLink(file.toPath()).toString()
|
||||
override fun readSymLink(file: File) = Files.readSymbolicLink(file.toPath()).toString()
|
||||
|
||||
override fun createSymLink(source: File, target: String) {
|
||||
val sourcePath = source.toPath()
|
||||
if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS))
|
||||
Files.delete(sourcePath)
|
||||
Files.createSymbolicLink(sourcePath, File(target).toPath())
|
||||
}
|
||||
override fun createSymLink(source: File, target: String) {
|
||||
val sourcePath = source.toPath()
|
||||
if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) Files.delete(sourcePath)
|
||||
Files.createSymbolicLink(sourcePath, File(target).toPath())
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private class Java7FSFactory : FS.FSFactory() {
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private class Java7FSFactory : FS.FSFactory() {
|
||||
|
||||
override fun detect(cygwinUsed: Boolean?): FS {
|
||||
return FS_POSIX_Java6_with_optional_symlinks()
|
||||
}
|
||||
override fun detect(cygwinUsed: Boolean?): FS {
|
||||
return FS_POSIX_Java6_with_optional_symlinks()
|
||||
}
|
||||
}
|
||||
|
||||
private var repository: Repository? = null
|
||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { Application.instance.sharedPrefs }
|
||||
private val filesDir
|
||||
get() = Application.instance.filesDir
|
||||
private var repository: Repository? = null
|
||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { Application.instance.sharedPrefs }
|
||||
private val filesDir
|
||||
get() = Application.instance.filesDir
|
||||
|
||||
/**
|
||||
* Returns the git repository
|
||||
*
|
||||
* @param localDir needed only on the creation
|
||||
* @return the git repository
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getRepository(localDir: File?): Repository? {
|
||||
if (repository == null && localDir != null) {
|
||||
val builder = FileRepositoryBuilder()
|
||||
repository = runCatching {
|
||||
builder.run {
|
||||
gitDir = localDir
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
fs = Java7FSFactory().detect(null)
|
||||
}
|
||||
readEnvironment()
|
||||
}.build()
|
||||
}.getOrElse { e ->
|
||||
e.printStackTrace()
|
||||
null
|
||||
/**
|
||||
* Returns the git repository
|
||||
*
|
||||
* @param localDir needed only on the creation
|
||||
* @return the git repository
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getRepository(localDir: File?): Repository? {
|
||||
if (repository == null && localDir != null) {
|
||||
val builder = FileRepositoryBuilder()
|
||||
repository =
|
||||
runCatching {
|
||||
builder
|
||||
.run {
|
||||
gitDir = localDir
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
fs = Java7FSFactory().detect(null)
|
||||
}
|
||||
readEnvironment()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
return repository
|
||||
.getOrElse { e ->
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
return repository
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val isInitialized: Boolean
|
||||
get() = repository != null
|
||||
@JvmStatic
|
||||
val isInitialized: Boolean
|
||||
get() = repository != null
|
||||
|
||||
@JvmStatic
|
||||
fun isGitRepo(): Boolean {
|
||||
if (repository != null) {
|
||||
return repository!!.objectDatabase.exists()
|
||||
@JvmStatic
|
||||
fun isGitRepo(): Boolean {
|
||||
if (repository != null) {
|
||||
return repository!!.objectDatabase.exists()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(Exception::class)
|
||||
fun createRepository(localDir: File) {
|
||||
localDir.delete()
|
||||
|
||||
Git.init().setDirectory(localDir).call()
|
||||
getRepository(localDir)
|
||||
}
|
||||
|
||||
// TODO add multiple remotes support for pull/push
|
||||
@JvmStatic
|
||||
fun addRemote(name: String, url: String, replace: Boolean = false) {
|
||||
val storedConfig = repository!!.config
|
||||
val remotes = storedConfig.getSubsections("remote")
|
||||
|
||||
if (!remotes.contains(name)) {
|
||||
runCatching {
|
||||
val uri = URIish(url)
|
||||
val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*")
|
||||
|
||||
val remoteConfig = RemoteConfig(storedConfig, name)
|
||||
remoteConfig.addFetchRefSpec(refSpec)
|
||||
remoteConfig.addPushRefSpec(refSpec)
|
||||
remoteConfig.addURI(uri)
|
||||
remoteConfig.addPushURI(uri)
|
||||
|
||||
remoteConfig.update(storedConfig)
|
||||
|
||||
storedConfig.save()
|
||||
}
|
||||
.onFailure { e -> e.printStackTrace() }
|
||||
} else if (replace) {
|
||||
runCatching {
|
||||
val uri = URIish(url)
|
||||
|
||||
val remoteConfig = RemoteConfig(storedConfig, name)
|
||||
// remove the first and eventually the only uri
|
||||
if (remoteConfig.urIs.size > 0) {
|
||||
remoteConfig.removeURI(remoteConfig.urIs[0])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(Exception::class)
|
||||
fun createRepository(localDir: File) {
|
||||
localDir.delete()
|
||||
|
||||
Git.init().setDirectory(localDir).call()
|
||||
getRepository(localDir)
|
||||
}
|
||||
|
||||
// TODO add multiple remotes support for pull/push
|
||||
@JvmStatic
|
||||
fun addRemote(name: String, url: String, replace: Boolean = false) {
|
||||
val storedConfig = repository!!.config
|
||||
val remotes = storedConfig.getSubsections("remote")
|
||||
|
||||
if (!remotes.contains(name)) {
|
||||
runCatching {
|
||||
val uri = URIish(url)
|
||||
val refSpec = RefSpec("+refs/head/*:refs/remotes/$name/*")
|
||||
|
||||
val remoteConfig = RemoteConfig(storedConfig, name)
|
||||
remoteConfig.addFetchRefSpec(refSpec)
|
||||
remoteConfig.addPushRefSpec(refSpec)
|
||||
remoteConfig.addURI(uri)
|
||||
remoteConfig.addPushURI(uri)
|
||||
|
||||
remoteConfig.update(storedConfig)
|
||||
|
||||
storedConfig.save()
|
||||
}.onFailure { e ->
|
||||
e.printStackTrace()
|
||||
}
|
||||
} else if (replace) {
|
||||
runCatching {
|
||||
val uri = URIish(url)
|
||||
|
||||
val remoteConfig = RemoteConfig(storedConfig, name)
|
||||
// remove the first and eventually the only uri
|
||||
if (remoteConfig.urIs.size > 0) {
|
||||
remoteConfig.removeURI(remoteConfig.urIs[0])
|
||||
}
|
||||
if (remoteConfig.pushURIs.size > 0) {
|
||||
remoteConfig.removePushURI(remoteConfig.pushURIs[0])
|
||||
}
|
||||
|
||||
remoteConfig.addURI(uri)
|
||||
remoteConfig.addPushURI(uri)
|
||||
|
||||
remoteConfig.update(storedConfig)
|
||||
|
||||
storedConfig.save()
|
||||
}.onFailure { e ->
|
||||
e.printStackTrace()
|
||||
}
|
||||
if (remoteConfig.pushURIs.size > 0) {
|
||||
remoteConfig.removePushURI(remoteConfig.pushURIs[0])
|
||||
}
|
||||
|
||||
remoteConfig.addURI(uri)
|
||||
remoteConfig.addPushURI(uri)
|
||||
|
||||
remoteConfig.update(storedConfig)
|
||||
|
||||
storedConfig.save()
|
||||
}
|
||||
.onFailure { e -> e.printStackTrace() }
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun closeRepository() {
|
||||
if (repository != null) repository!!.close()
|
||||
repository = null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getRepositoryDirectory(): File {
|
||||
return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
|
||||
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
if (externalRepo != null) File(externalRepo) else File(filesDir.toString(), "/store")
|
||||
} else {
|
||||
File(filesDir.toString(), "/store")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun initialize(): Repository? {
|
||||
val dir = getRepositoryDirectory()
|
||||
// uninitialize the repo if the dir does not exist or is absolutely empty
|
||||
settings.edit {
|
||||
if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) {
|
||||
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
|
||||
} else {
|
||||
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun closeRepository() {
|
||||
if (repository != null) repository!!.close()
|
||||
repository = null
|
||||
}
|
||||
// create the repository static variable in PasswordRepository
|
||||
return getRepository(File(dir.absolutePath + "/.git"))
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getRepositoryDirectory(): File {
|
||||
return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
|
||||
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
if (externalRepo != null)
|
||||
File(externalRepo)
|
||||
else
|
||||
File(filesDir.toString(), "/store")
|
||||
/**
|
||||
* Gets the .gpg files in a directory
|
||||
*
|
||||
* @param path the directory path
|
||||
* @return the list of gpg files in that directory
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getFilesList(path: File?): ArrayList<File> {
|
||||
if (path == null || !path.exists()) return ArrayList()
|
||||
|
||||
val directories = (path.listFiles(FileFilter { pathname -> pathname.isDirectory }) ?: emptyArray()).toList()
|
||||
val files = (path.listFiles(FileFilter { pathname -> pathname.extension == "gpg" }) ?: emptyArray()).toList()
|
||||
|
||||
val items = ArrayList<File>()
|
||||
items.addAll(directories)
|
||||
items.addAll(files)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the passwords (PasswordItem) in a directory
|
||||
*
|
||||
* @param path the directory path
|
||||
* @return a list of password items
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getPasswords(path: File, rootDir: File, sortOrder: PasswordSortOrder): ArrayList<PasswordItem> {
|
||||
// We need to recover the passwords then parse the files
|
||||
val passList = getFilesList(path).also { it.sortBy { f -> f.name } }
|
||||
val passwordList = ArrayList<PasswordItem>()
|
||||
val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
|
||||
|
||||
if (passList.size == 0) return passwordList
|
||||
if (!showHidden) {
|
||||
passList.filter { !it.isHidden }.toCollection(passList.apply { clear() })
|
||||
}
|
||||
passList.forEach { file ->
|
||||
passwordList.add(
|
||||
if (file.isFile) {
|
||||
PasswordItem.newPassword(file.name, file, rootDir)
|
||||
} else {
|
||||
File(filesDir.toString(), "/store")
|
||||
PasswordItem.newCategory(file.name, file, rootDir)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun initialize(): Repository? {
|
||||
val dir = getRepositoryDirectory()
|
||||
// uninitialize the repo if the dir does not exist or is absolutely empty
|
||||
settings.edit {
|
||||
if (!dir.exists() || !dir.isDirectory || requireNotNull(dir.listFiles()).isEmpty()) {
|
||||
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
|
||||
} else {
|
||||
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true)
|
||||
}
|
||||
}
|
||||
|
||||
// create the repository static variable in PasswordRepository
|
||||
return getRepository(File(dir.absolutePath + "/.git"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the .gpg files in a directory
|
||||
*
|
||||
* @param path the directory path
|
||||
* @return the list of gpg files in that directory
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getFilesList(path: File?): ArrayList<File> {
|
||||
if (path == null || !path.exists()) return ArrayList()
|
||||
|
||||
val directories = (path.listFiles(FileFilter { pathname -> pathname.isDirectory })
|
||||
?: emptyArray()).toList()
|
||||
val files = (path.listFiles(FileFilter { pathname -> pathname.extension == "gpg" })
|
||||
?: emptyArray()).toList()
|
||||
|
||||
val items = ArrayList<File>()
|
||||
items.addAll(directories)
|
||||
items.addAll(files)
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the passwords (PasswordItem) in a directory
|
||||
*
|
||||
* @param path the directory path
|
||||
* @return a list of password items
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getPasswords(path: File, rootDir: File, sortOrder: PasswordSortOrder): ArrayList<PasswordItem> {
|
||||
// We need to recover the passwords then parse the files
|
||||
val passList = getFilesList(path).also { it.sortBy { f -> f.name } }
|
||||
val passwordList = ArrayList<PasswordItem>()
|
||||
val showHidden = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
|
||||
|
||||
if (passList.size == 0) return passwordList
|
||||
if (!showHidden) {
|
||||
passList.filter { !it.isHidden }.toCollection(passList.apply { clear() })
|
||||
}
|
||||
passList.forEach { file ->
|
||||
passwordList.add(if (file.isFile) {
|
||||
PasswordItem.newPassword(file.name, file, rootDir)
|
||||
} else {
|
||||
PasswordItem.newCategory(file.name, file, rootDir)
|
||||
})
|
||||
}
|
||||
passwordList.sortWith(sortOrder.comparator)
|
||||
return passwordList
|
||||
}
|
||||
passwordList.sortWith(sortOrder.comparator)
|
||||
return passwordList
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,74 +17,74 @@ import dev.msfjarvis.aps.data.password.FieldItem
|
|||
import dev.msfjarvis.aps.databinding.ItemFieldBinding
|
||||
|
||||
class FieldItemAdapter(
|
||||
private var fieldItemList: List<FieldItem>,
|
||||
private val showPassword: Boolean,
|
||||
private val copyTextToClipBoard: (text: String?) -> Unit,
|
||||
private var fieldItemList: List<FieldItem>,
|
||||
private val showPassword: Boolean,
|
||||
private val copyTextToClipBoard: (text: String?) -> Unit,
|
||||
) : RecyclerView.Adapter<FieldItemAdapter.FieldItemViewHolder>() {
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
|
||||
val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return FieldItemViewHolder(binding.root, binding)
|
||||
}
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FieldItemViewHolder {
|
||||
val binding = ItemFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return FieldItemViewHolder(binding.root, binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
|
||||
holder.bind(fieldItemList[position], showPassword, copyTextToClipBoard)
|
||||
}
|
||||
override fun onBindViewHolder(holder: FieldItemViewHolder, position: Int) {
|
||||
holder.bind(fieldItemList[position], showPassword, copyTextToClipBoard)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
return fieldItemList.size
|
||||
}
|
||||
override fun getItemCount(): Int {
|
||||
return fieldItemList.size
|
||||
}
|
||||
|
||||
fun updateOTPCode(code: String) {
|
||||
var otpItemPosition = -1;
|
||||
fieldItemList = fieldItemList.mapIndexed { position, item ->
|
||||
if (item.key.equals(FieldItem.ItemType.OTP.type, true)) {
|
||||
otpItemPosition = position
|
||||
return@mapIndexed FieldItem.createOtpField(code)
|
||||
}
|
||||
|
||||
return@mapIndexed item
|
||||
fun updateOTPCode(code: String) {
|
||||
var otpItemPosition = -1
|
||||
fieldItemList =
|
||||
fieldItemList.mapIndexed { position, item ->
|
||||
if (item.key.equals(FieldItem.ItemType.OTP.type, true)) {
|
||||
otpItemPosition = position
|
||||
return@mapIndexed FieldItem.createOtpField(code)
|
||||
}
|
||||
|
||||
notifyItemChanged(otpItemPosition)
|
||||
}
|
||||
return@mapIndexed item
|
||||
}
|
||||
|
||||
fun updateItems(itemList: List<FieldItem>) {
|
||||
fieldItemList = itemList
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
notifyItemChanged(otpItemPosition)
|
||||
}
|
||||
|
||||
class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) :
|
||||
RecyclerView.ViewHolder(itemView) {
|
||||
fun updateItems(itemList: List<FieldItem>) {
|
||||
fieldItemList = itemList
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) {
|
||||
with(binding) {
|
||||
itemText.hint = fieldItem.key
|
||||
itemTextContainer.hint = fieldItem.key
|
||||
itemText.setText(fieldItem.value)
|
||||
class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : RecyclerView.ViewHolder(itemView) {
|
||||
|
||||
when (fieldItem.action) {
|
||||
FieldItem.ActionType.COPY -> {
|
||||
itemTextContainer.apply {
|
||||
endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
|
||||
endIconMode = TextInputLayout.END_ICON_CUSTOM
|
||||
setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
||||
}
|
||||
}
|
||||
FieldItem.ActionType.HIDE -> {
|
||||
itemTextContainer.apply {
|
||||
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
|
||||
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
||||
}
|
||||
itemText.apply {
|
||||
if (!showPassword) {
|
||||
transformationMethod = PasswordTransformationMethod.getInstance()
|
||||
}
|
||||
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) {
|
||||
with(binding) {
|
||||
itemText.hint = fieldItem.key
|
||||
itemTextContainer.hint = fieldItem.key
|
||||
itemText.setText(fieldItem.value)
|
||||
|
||||
when (fieldItem.action) {
|
||||
FieldItem.ActionType.COPY -> {
|
||||
itemTextContainer.apply {
|
||||
endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
|
||||
endIconMode = TextInputLayout.END_ICON_CUSTOM
|
||||
setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
||||
}
|
||||
}
|
||||
FieldItem.ActionType.HIDE -> {
|
||||
itemTextContainer.apply {
|
||||
endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
|
||||
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
||||
}
|
||||
itemText.apply {
|
||||
if (!showPassword) {
|
||||
transformationMethod = PasswordTransformationMethod.getInstance()
|
||||
}
|
||||
setOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,65 +19,66 @@ import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryAdapter
|
|||
import dev.msfjarvis.aps.util.viewmodel.stableId
|
||||
|
||||
open class PasswordItemRecyclerAdapter :
|
||||
SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>(
|
||||
R.layout.password_row_layout,
|
||||
::PasswordItemViewHolder,
|
||||
PasswordItemViewHolder::bind
|
||||
) {
|
||||
SearchableRepositoryAdapter<PasswordItemRecyclerAdapter.PasswordItemViewHolder>(
|
||||
R.layout.password_row_layout,
|
||||
::PasswordItemViewHolder,
|
||||
PasswordItemViewHolder::bind
|
||||
) {
|
||||
|
||||
fun makeSelectable(recyclerView: RecyclerView) {
|
||||
makeSelectable(recyclerView, ::PasswordItemDetailsLookup)
|
||||
}
|
||||
fun makeSelectable(recyclerView: RecyclerView) {
|
||||
makeSelectable(recyclerView, ::PasswordItemDetailsLookup)
|
||||
}
|
||||
|
||||
override fun onItemClicked(listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit): PasswordItemRecyclerAdapter {
|
||||
return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
|
||||
}
|
||||
override fun onItemClicked(
|
||||
listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit
|
||||
): PasswordItemRecyclerAdapter {
|
||||
return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter {
|
||||
return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter
|
||||
}
|
||||
override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter {
|
||||
return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter
|
||||
}
|
||||
|
||||
class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
class PasswordItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
|
||||
private val name: AppCompatTextView = itemView.findViewById(R.id.label)
|
||||
private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count)
|
||||
private val folderIndicator: AppCompatImageView =
|
||||
itemView.findViewById(R.id.folder_indicator)
|
||||
lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String>
|
||||
private val name: AppCompatTextView = itemView.findViewById(R.id.label)
|
||||
private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count)
|
||||
private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator)
|
||||
lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String>
|
||||
|
||||
fun bind(item: PasswordItem) {
|
||||
val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "")
|
||||
val source = if (parentPath.isNotEmpty()) {
|
||||
"$parentPath\n$item"
|
||||
} else {
|
||||
"$item"
|
||||
}
|
||||
val spannable = SpannableString(source)
|
||||
spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0)
|
||||
name.text = spannable
|
||||
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
||||
folderIndicator.visibility = View.VISIBLE
|
||||
val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size
|
||||
?: 0
|
||||
childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
|
||||
childCount.text = "$count"
|
||||
} else {
|
||||
childCount.visibility = View.GONE
|
||||
folderIndicator.visibility = View.GONE
|
||||
}
|
||||
itemDetails = object : ItemDetailsLookup.ItemDetails<String>() {
|
||||
override fun getPosition() = absoluteAdapterPosition
|
||||
override fun getSelectionKey() = item.stableId
|
||||
}
|
||||
fun bind(item: PasswordItem) {
|
||||
val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "")
|
||||
val source =
|
||||
if (parentPath.isNotEmpty()) {
|
||||
"$parentPath\n$item"
|
||||
} else {
|
||||
"$item"
|
||||
}
|
||||
val spannable = SpannableString(source)
|
||||
spannable.setSpan(RelativeSizeSpan(0.7f), 0, parentPath.length, 0)
|
||||
name.text = spannable
|
||||
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
||||
folderIndicator.visibility = View.VISIBLE
|
||||
val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0
|
||||
childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
|
||||
childCount.text = "$count"
|
||||
} else {
|
||||
childCount.visibility = View.GONE
|
||||
folderIndicator.visibility = View.GONE
|
||||
}
|
||||
itemDetails =
|
||||
object : ItemDetailsLookup.ItemDetails<String>() {
|
||||
override fun getPosition() = absoluteAdapterPosition
|
||||
override fun getSelectionKey() = item.stableId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) :
|
||||
ItemDetailsLookup<String>() {
|
||||
class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup<String>() {
|
||||
|
||||
override fun getItemDetails(event: MotionEvent): ItemDetails<String>? {
|
||||
val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null
|
||||
return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails
|
||||
}
|
||||
override fun getItemDetails(event: MotionEvent): ItemDetails<String>? {
|
||||
val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null
|
||||
return (recyclerView.getChildViewHolder(view) as PasswordItemViewHolder).itemDetails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,195 +51,184 @@ import org.openintents.openpgp.OpenPgpError
|
|||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH"
|
||||
private const val EXTRA_SEARCH_ACTION =
|
||||
"dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION"
|
||||
private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH"
|
||||
private const val EXTRA_SEARCH_ACTION = "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION"
|
||||
|
||||
private var decryptFileRequestCode = 1
|
||||
private var decryptFileRequestCode = 1
|
||||
|
||||
fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
|
||||
return Intent(context, AutofillDecryptActivity::class.java).apply {
|
||||
putExtras(forwardedExtras)
|
||||
putExtra(EXTRA_SEARCH_ACTION, true)
|
||||
putExtra(EXTRA_FILE_PATH, file.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
|
||||
val intent = Intent(context, AutofillDecryptActivity::class.java).apply {
|
||||
putExtra(EXTRA_SEARCH_ACTION, false)
|
||||
putExtra(EXTRA_FILE_PATH, file.absolutePath)
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
decryptFileRequestCode++,
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
).intentSender
|
||||
}
|
||||
fun makeDecryptFileIntent(file: File, forwardedExtras: Bundle, context: Context): Intent {
|
||||
return Intent(context, AutofillDecryptActivity::class.java).apply {
|
||||
putExtras(forwardedExtras)
|
||||
putExtra(EXTRA_SEARCH_ACTION, true)
|
||||
putExtra(EXTRA_FILE_PATH, file.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
private val decryptInteractionRequiredAction = registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||
if (continueAfterUserInteraction != null) {
|
||||
val data = result.data
|
||||
if (result.resultCode == RESULT_OK && data != null) {
|
||||
continueAfterUserInteraction?.resume(data)
|
||||
} else {
|
||||
continueAfterUserInteraction?.resumeWithException(Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction"))
|
||||
}
|
||||
continueAfterUserInteraction = null
|
||||
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
|
||||
val intent =
|
||||
Intent(context, AutofillDecryptActivity::class.java).apply {
|
||||
putExtra(EXTRA_SEARCH_ACTION, false)
|
||||
putExtra(EXTRA_FILE_PATH, file.absolutePath)
|
||||
}
|
||||
return PendingIntent.getActivity(context, decryptFileRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
|
||||
.intentSender
|
||||
}
|
||||
}
|
||||
|
||||
private val decryptInteractionRequiredAction =
|
||||
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||
if (continueAfterUserInteraction != null) {
|
||||
val data = result.data
|
||||
if (result.resultCode == RESULT_OK && data != null) {
|
||||
continueAfterUserInteraction?.resume(data)
|
||||
} else {
|
||||
continueAfterUserInteraction?.resumeWithException(
|
||||
Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")
|
||||
)
|
||||
}
|
||||
continueAfterUserInteraction = null
|
||||
}
|
||||
}
|
||||
|
||||
private var continueAfterUserInteraction: Continuation<Intent>? = null
|
||||
private lateinit var directoryStructure: DirectoryStructure
|
||||
private var continueAfterUserInteraction: Continuation<Intent>? = null
|
||||
private lateinit var directoryStructure: DirectoryStructure
|
||||
|
||||
override val coroutineContext
|
||||
get() = Dispatchers.IO + SupervisorJob()
|
||||
override val coroutineContext
|
||||
get() = Dispatchers.IO + SupervisorJob()
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run {
|
||||
e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
|
||||
finish()
|
||||
return
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
val filePath =
|
||||
intent?.getStringExtra(EXTRA_FILE_PATH)
|
||||
?: run {
|
||||
e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
|
||||
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
|
||||
finish()
|
||||
return
|
||||
val clientState =
|
||||
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
|
||||
?: run {
|
||||
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
|
||||
val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
|
||||
directoryStructure = AutofillPreferences.directoryStructure(this)
|
||||
d { action.toString() }
|
||||
launch {
|
||||
val credentials = decryptCredential(File(filePath))
|
||||
if (credentials == null) {
|
||||
setResult(RESULT_CANCELED)
|
||||
} else {
|
||||
val fillInDataset =
|
||||
AutofillResponseBuilder.makeFillInDataset(
|
||||
this@AutofillDecryptActivity,
|
||||
credentials,
|
||||
clientState,
|
||||
action
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
setResult(RESULT_OK, Intent().apply {
|
||||
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
|
||||
})
|
||||
val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!!
|
||||
val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match
|
||||
directoryStructure = AutofillPreferences.directoryStructure(this)
|
||||
d { action.toString() }
|
||||
launch {
|
||||
val credentials = decryptCredential(File(filePath))
|
||||
if (credentials == null) {
|
||||
setResult(RESULT_CANCELED)
|
||||
} else {
|
||||
val fillInDataset =
|
||||
AutofillResponseBuilder.makeFillInDataset(this@AutofillDecryptActivity, credentials, clientState, action)
|
||||
withContext(Dispatchers.Main) {
|
||||
setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) })
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) { finish() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
coroutineContext.cancelChildren()
|
||||
}
|
||||
|
||||
private suspend fun executeOpenPgpApi(data: Intent, input: InputStream, output: OutputStream): Intent? {
|
||||
var openPgpServiceConnection: OpenPgpServiceConnection? = null
|
||||
val openPgpService =
|
||||
suspendCoroutine<IOpenPgpService2> { cont ->
|
||||
openPgpServiceConnection =
|
||||
OpenPgpServiceConnection(
|
||||
this,
|
||||
OPENPGP_PROVIDER,
|
||||
object : OpenPgpServiceConnection.OnBound {
|
||||
override fun onBound(service: IOpenPgpService2) {
|
||||
cont.resume(service)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
coroutineContext.cancelChildren()
|
||||
}
|
||||
|
||||
private suspend fun executeOpenPgpApi(
|
||||
data: Intent,
|
||||
input: InputStream,
|
||||
output: OutputStream
|
||||
): Intent? {
|
||||
var openPgpServiceConnection: OpenPgpServiceConnection? = null
|
||||
val openPgpService = suspendCoroutine<IOpenPgpService2> { cont ->
|
||||
openPgpServiceConnection = OpenPgpServiceConnection(
|
||||
this,
|
||||
OPENPGP_PROVIDER,
|
||||
object : OpenPgpServiceConnection.OnBound {
|
||||
override fun onBound(service: IOpenPgpService2) {
|
||||
cont.resume(service)
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
}).also { it.bindToService() }
|
||||
}
|
||||
return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
|
||||
openPgpServiceConnection?.unbindFromService()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decryptCredential(
|
||||
file: File,
|
||||
resumeIntent: Intent? = null
|
||||
): Credentials? {
|
||||
val command = resumeIntent ?: Intent().apply {
|
||||
action = OpenPgpApi.ACTION_DECRYPT_VERIFY
|
||||
}
|
||||
runCatching {
|
||||
file.inputStream()
|
||||
}.onFailure { e ->
|
||||
e(e) { "File to decrypt not found" }
|
||||
return null
|
||||
}.onSuccess { encryptedInput ->
|
||||
val decryptedOutput = ByteArrayOutputStream()
|
||||
runCatching {
|
||||
executeOpenPgpApi(command, encryptedInput, decryptedOutput)
|
||||
}.onFailure { e ->
|
||||
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" }
|
||||
return null
|
||||
}.onSuccess { result ->
|
||||
return when (val resultCode =
|
||||
result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||
runCatching {
|
||||
val entry = withContext(Dispatchers.IO) {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
(PasswordEntry(decryptedOutput))
|
||||
}
|
||||
AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
|
||||
}.getOrElse { e ->
|
||||
e(e) { "Failed to parse password entry" }
|
||||
return null
|
||||
}
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val pendingIntent: PendingIntent =
|
||||
result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!
|
||||
runCatching {
|
||||
val intentToResume = withContext(Dispatchers.Main) {
|
||||
suspendCoroutine<Intent> { cont ->
|
||||
continueAfterUserInteraction = cont
|
||||
decryptInteractionRequiredAction.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build())
|
||||
}
|
||||
}
|
||||
decryptCredential(file, intentToResume)
|
||||
}.getOrElse { e ->
|
||||
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" }
|
||||
return null
|
||||
}
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_ERROR -> {
|
||||
val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR)
|
||||
if (error != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
applicationContext,
|
||||
"Error from OpenKeyChain: ${error.message}",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" }
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
e { "Unrecognized OpenPgpApi result: $resultCode" }
|
||||
null
|
||||
}
|
||||
override fun onError(e: Exception) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
.also { it.bindToService() }
|
||||
}
|
||||
return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
|
||||
openPgpServiceConnection?.unbindFromService()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decryptCredential(file: File, resumeIntent: Intent? = null): Credentials? {
|
||||
val command = resumeIntent ?: Intent().apply { action = OpenPgpApi.ACTION_DECRYPT_VERIFY }
|
||||
runCatching { file.inputStream() }
|
||||
.onFailure { e ->
|
||||
e(e) { "File to decrypt not found" }
|
||||
return null
|
||||
}
|
||||
}
|
||||
.onSuccess { encryptedInput ->
|
||||
val decryptedOutput = ByteArrayOutputStream()
|
||||
runCatching { executeOpenPgpApi(command, encryptedInput, decryptedOutput) }
|
||||
.onFailure { e ->
|
||||
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" }
|
||||
return null
|
||||
}
|
||||
.onSuccess { result ->
|
||||
return when (val resultCode = result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||
runCatching {
|
||||
val entry =
|
||||
withContext(Dispatchers.IO) {
|
||||
@Suppress("BlockingMethodInNonBlockingContext") (PasswordEntry(decryptedOutput))
|
||||
}
|
||||
AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
|
||||
}
|
||||
.getOrElse { e ->
|
||||
e(e) { "Failed to parse password entry" }
|
||||
return null
|
||||
}
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val pendingIntent: PendingIntent = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!
|
||||
runCatching {
|
||||
val intentToResume =
|
||||
withContext(Dispatchers.Main) {
|
||||
suspendCoroutine<Intent> { cont ->
|
||||
continueAfterUserInteraction = cont
|
||||
decryptInteractionRequiredAction.launch(
|
||||
IntentSenderRequest.Builder(pendingIntent.intentSender).build()
|
||||
)
|
||||
}
|
||||
}
|
||||
decryptCredential(file, intentToResume)
|
||||
}
|
||||
.getOrElse { e ->
|
||||
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" }
|
||||
return null
|
||||
}
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_ERROR -> {
|
||||
val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR)
|
||||
if (error != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(applicationContext, "Error from OpenKeyChain: ${error.message}", Toast.LENGTH_LONG)
|
||||
.show()
|
||||
}
|
||||
e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" }
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
e { "Unrecognized OpenPgpApi result: $resultCode" }
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,180 +41,164 @@ import dev.msfjarvis.aps.util.viewmodel.SearchableRepositoryViewModel
|
|||
@TargetApi(Build.VERSION_CODES.O)
|
||||
class AutofillFilterView : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
private const val HEIGHT_PERCENTAGE = 0.9
|
||||
private const val WIDTH_PERCENTAGE = 0.75
|
||||
private const val HEIGHT_PERCENTAGE = 0.9
|
||||
private const val WIDTH_PERCENTAGE = 0.75
|
||||
|
||||
private const val EXTRA_FORM_ORIGIN_WEB =
|
||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB"
|
||||
private const val EXTRA_FORM_ORIGIN_APP =
|
||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
|
||||
private var matchAndDecryptFileRequestCode = 1
|
||||
private const val EXTRA_FORM_ORIGIN_WEB = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB"
|
||||
private const val EXTRA_FORM_ORIGIN_APP = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
|
||||
private var matchAndDecryptFileRequestCode = 1
|
||||
|
||||
fun makeMatchAndDecryptFileIntentSender(
|
||||
context: Context,
|
||||
formOrigin: FormOrigin
|
||||
): IntentSender {
|
||||
val intent = Intent(context, AutofillFilterView::class.java).apply {
|
||||
when (formOrigin) {
|
||||
is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier)
|
||||
is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
|
||||
}
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
matchAndDecryptFileRequestCode++,
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
).intentSender
|
||||
fun makeMatchAndDecryptFileIntentSender(context: Context, formOrigin: FormOrigin): IntentSender {
|
||||
val intent =
|
||||
Intent(context, AutofillFilterView::class.java).apply {
|
||||
when (formOrigin) {
|
||||
is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier)
|
||||
is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var formOrigin: FormOrigin
|
||||
private lateinit var directoryStructure: DirectoryStructure
|
||||
private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate)
|
||||
|
||||
private val model: SearchableRepositoryViewModel by viewModels {
|
||||
ViewModelProvider.AndroidViewModelFactory(application)
|
||||
}
|
||||
|
||||
private val decryptAction = registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
setResult(RESULT_OK, result.data)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
setFinishOnTouchOutside(true)
|
||||
|
||||
val params = window.attributes
|
||||
params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt()
|
||||
params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
|
||||
window.attributes = params
|
||||
|
||||
if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
|
||||
e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
|
||||
finish()
|
||||
return
|
||||
}
|
||||
formOrigin = when {
|
||||
intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> {
|
||||
FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!)
|
||||
}
|
||||
intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> {
|
||||
FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
|
||||
}
|
||||
else -> {
|
||||
e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" }
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
directoryStructure = AutofillPreferences.directoryStructure(this)
|
||||
|
||||
supportActionBar?.hide()
|
||||
bindUI()
|
||||
updateSearch()
|
||||
setResult(RESULT_CANCELED)
|
||||
}
|
||||
|
||||
private fun bindUI() {
|
||||
with(binding) {
|
||||
rvPassword.apply {
|
||||
adapter = SearchableRepositoryAdapter(
|
||||
R.layout.oreo_autofill_filter_row,
|
||||
::PasswordViewHolder
|
||||
) { item ->
|
||||
val file = item.file.relativeTo(item.rootDir)
|
||||
val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
|
||||
val identifier = directoryStructure.getIdentifierFor(file)
|
||||
val accountPart = directoryStructure.getAccountPartFor(file)
|
||||
check(identifier != null || accountPart != null) { "At least one of identifier and accountPart should always be non-null" }
|
||||
title.text = if (identifier != null) {
|
||||
buildSpannedString {
|
||||
if (pathToIdentifier != null)
|
||||
append("$pathToIdentifier/")
|
||||
bold { underline { append(identifier) } }
|
||||
}
|
||||
} else {
|
||||
accountPart
|
||||
}
|
||||
subtitle.apply {
|
||||
if (identifier != null && accountPart != null) {
|
||||
text = accountPart
|
||||
visibility = View.VISIBLE
|
||||
} else {
|
||||
visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}.onItemClicked { _, item ->
|
||||
decryptAndFill(item)
|
||||
}
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
}
|
||||
search.apply {
|
||||
val initialSearch =
|
||||
formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
|
||||
setText(initialSearch, TextView.BufferType.EDITABLE)
|
||||
addTextChangedListener { updateSearch() }
|
||||
}
|
||||
origin.text = buildSpannedString {
|
||||
append(getString(R.string.oreo_autofill_select_and_fill_into))
|
||||
append("\n")
|
||||
bold {
|
||||
append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true))
|
||||
}
|
||||
}
|
||||
strictDomainSearch.apply {
|
||||
visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE
|
||||
isChecked = formOrigin is FormOrigin.Web
|
||||
setOnCheckedChangeListener { _, _ -> updateSearch() }
|
||||
}
|
||||
shouldMatch.text = getString(
|
||||
R.string.oreo_autofill_match_with,
|
||||
formOrigin.getPrettyIdentifier(applicationContext)
|
||||
)
|
||||
model.searchResult.observe(this@AutofillFilterView) { result ->
|
||||
val list = result.passwordItems
|
||||
(rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) {
|
||||
rvPassword.scrollToPosition(0)
|
||||
}
|
||||
// Switch RecyclerView out for a "no results" message if the new list is empty and
|
||||
// the message is not yet shown (and vice versa).
|
||||
if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) ||
|
||||
(list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id)
|
||||
) {
|
||||
rvPasswordSwitcher.showNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSearch() {
|
||||
model.search(
|
||||
binding.search.text.toString().trim(),
|
||||
filterMode = if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy,
|
||||
searchMode = SearchMode.RecursivelyInSubdirectories,
|
||||
listMode = ListMode.FilesOnly
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
matchAndDecryptFileRequestCode++,
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
)
|
||||
.intentSender
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var formOrigin: FormOrigin
|
||||
private lateinit var directoryStructure: DirectoryStructure
|
||||
private val binding by viewBinding(ActivityOreoAutofillFilterBinding::inflate)
|
||||
|
||||
private val model: SearchableRepositoryViewModel by viewModels {
|
||||
ViewModelProvider.AndroidViewModelFactory(application)
|
||||
}
|
||||
|
||||
private val decryptAction =
|
||||
registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
setResult(RESULT_OK, result.data)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun decryptAndFill(item: PasswordItem) {
|
||||
if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
|
||||
if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor(
|
||||
applicationContext,
|
||||
formOrigin,
|
||||
item.file
|
||||
)
|
||||
// intent?.extras? is checked to be non-null in onCreate
|
||||
decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent(
|
||||
item.file,
|
||||
intent!!.extras!!,
|
||||
this
|
||||
))
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
setFinishOnTouchOutside(true)
|
||||
|
||||
val params = window.attributes
|
||||
params.height = (HEIGHT_PERCENTAGE * resources.displayMetrics.heightPixels).toInt()
|
||||
params.width = (WIDTH_PERCENTAGE * resources.displayMetrics.widthPixels).toInt()
|
||||
window.attributes = params
|
||||
|
||||
if (intent?.hasExtra(AutofillManager.EXTRA_CLIENT_STATE) != true) {
|
||||
e { "AutofillFilterActivity started without EXTRA_CLIENT_STATE" }
|
||||
finish()
|
||||
return
|
||||
}
|
||||
formOrigin =
|
||||
when {
|
||||
intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> {
|
||||
FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!)
|
||||
}
|
||||
intent?.hasExtra(EXTRA_FORM_ORIGIN_APP) == true -> {
|
||||
FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
|
||||
}
|
||||
else -> {
|
||||
e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" }
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
directoryStructure = AutofillPreferences.directoryStructure(this)
|
||||
|
||||
supportActionBar?.hide()
|
||||
bindUI()
|
||||
updateSearch()
|
||||
setResult(RESULT_CANCELED)
|
||||
}
|
||||
|
||||
private fun bindUI() {
|
||||
with(binding) {
|
||||
rvPassword.apply {
|
||||
adapter =
|
||||
SearchableRepositoryAdapter(R.layout.oreo_autofill_filter_row, ::PasswordViewHolder) { item ->
|
||||
val file = item.file.relativeTo(item.rootDir)
|
||||
val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
|
||||
val identifier = directoryStructure.getIdentifierFor(file)
|
||||
val accountPart = directoryStructure.getAccountPartFor(file)
|
||||
check(identifier != null || accountPart != null) {
|
||||
"At least one of identifier and accountPart should always be non-null"
|
||||
}
|
||||
title.text =
|
||||
if (identifier != null) {
|
||||
buildSpannedString {
|
||||
if (pathToIdentifier != null) append("$pathToIdentifier/")
|
||||
bold { underline { append(identifier) } }
|
||||
}
|
||||
} else {
|
||||
accountPart
|
||||
}
|
||||
subtitle.apply {
|
||||
if (identifier != null && accountPart != null) {
|
||||
text = accountPart
|
||||
visibility = View.VISIBLE
|
||||
} else {
|
||||
visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
.onItemClicked { _, item -> decryptAndFill(item) }
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
}
|
||||
search.apply {
|
||||
val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
|
||||
setText(initialSearch, TextView.BufferType.EDITABLE)
|
||||
addTextChangedListener { updateSearch() }
|
||||
}
|
||||
origin.text =
|
||||
buildSpannedString {
|
||||
append(getString(R.string.oreo_autofill_select_and_fill_into))
|
||||
append("\n")
|
||||
bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) }
|
||||
}
|
||||
strictDomainSearch.apply {
|
||||
visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE
|
||||
isChecked = formOrigin is FormOrigin.Web
|
||||
setOnCheckedChangeListener { _, _ -> updateSearch() }
|
||||
}
|
||||
shouldMatch.text =
|
||||
getString(R.string.oreo_autofill_match_with, formOrigin.getPrettyIdentifier(applicationContext))
|
||||
model.searchResult.observe(this@AutofillFilterView) { result ->
|
||||
val list = result.passwordItems
|
||||
(rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { rvPassword.scrollToPosition(0) }
|
||||
// Switch RecyclerView out for a "no results" message if the new list is empty and
|
||||
// the message is not yet shown (and vice versa).
|
||||
if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) ||
|
||||
(list.isNotEmpty() && rvPasswordSwitcher.nextView.id == rvPassword.id)
|
||||
) {
|
||||
rvPasswordSwitcher.showNext()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSearch() {
|
||||
model.search(
|
||||
binding.search.text.toString().trim(),
|
||||
filterMode = if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy,
|
||||
searchMode = SearchMode.RecursivelyInSubdirectories,
|
||||
listMode = ListMode.FilesOnly
|
||||
)
|
||||
}
|
||||
|
||||
private fun decryptAndFill(item: PasswordItem) {
|
||||
if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
|
||||
if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file)
|
||||
// intent?.extras? is checked to be non-null in onCreate
|
||||
decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,84 +31,83 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
|
|||
@TargetApi(Build.VERSION_CODES.O)
|
||||
class AutofillPublisherChangedActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_APP_PACKAGE =
|
||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE"
|
||||
private const val EXTRA_FILL_RESPONSE_AFTER_RESET =
|
||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET"
|
||||
private var publisherChangedRequestCode = 1
|
||||
private const val EXTRA_APP_PACKAGE = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE"
|
||||
private const val EXTRA_FILL_RESPONSE_AFTER_RESET =
|
||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET"
|
||||
private var publisherChangedRequestCode = 1
|
||||
|
||||
fun makePublisherChangedIntentSender(
|
||||
context: Context,
|
||||
publisherChangedException: AutofillPublisherChangedException,
|
||||
fillResponseAfterReset: FillResponse?,
|
||||
): IntentSender {
|
||||
val intent = Intent(context, AutofillPublisherChangedActivity::class.java).apply {
|
||||
putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier)
|
||||
putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset)
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
context, publisherChangedRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT
|
||||
).intentSender
|
||||
fun makePublisherChangedIntentSender(
|
||||
context: Context,
|
||||
publisherChangedException: AutofillPublisherChangedException,
|
||||
fillResponseAfterReset: FillResponse?,
|
||||
): IntentSender {
|
||||
val intent =
|
||||
Intent(context, AutofillPublisherChangedActivity::class.java).apply {
|
||||
putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier)
|
||||
putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset)
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
publisherChangedRequestCode++,
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
)
|
||||
.intentSender
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var appPackage: String
|
||||
private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate)
|
||||
private lateinit var appPackage: String
|
||||
private val binding by viewBinding(ActivityOreoAutofillPublisherChangedBinding::inflate)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
setFinishOnTouchOutside(true)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
setFinishOnTouchOutside(true)
|
||||
|
||||
appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run {
|
||||
e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
|
||||
finish()
|
||||
return
|
||||
}
|
||||
supportActionBar?.hide()
|
||||
showPackageInfo()
|
||||
with(binding) {
|
||||
okButton.setOnClickListener { finish() }
|
||||
advancedButton.setOnClickListener {
|
||||
advancedButton.visibility = View.GONE
|
||||
warningAppAdvancedInfo.visibility = View.VISIBLE
|
||||
resetButton.visibility = View.VISIBLE
|
||||
}
|
||||
resetButton.setOnClickListener {
|
||||
AutofillMatcher.clearMatchesFor(this@AutofillPublisherChangedActivity, FormOrigin.App(appPackage))
|
||||
val fillResponse = intent.getParcelableExtra<FillResponse>(EXTRA_FILL_RESPONSE_AFTER_RESET)
|
||||
setResult(RESULT_OK, Intent().apply {
|
||||
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
appPackage =
|
||||
intent.getStringExtra(EXTRA_APP_PACKAGE)
|
||||
?: run {
|
||||
e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
|
||||
finish()
|
||||
return
|
||||
}
|
||||
supportActionBar?.hide()
|
||||
showPackageInfo()
|
||||
with(binding) {
|
||||
okButton.setOnClickListener { finish() }
|
||||
advancedButton.setOnClickListener {
|
||||
advancedButton.visibility = View.GONE
|
||||
warningAppAdvancedInfo.visibility = View.VISIBLE
|
||||
resetButton.visibility = View.VISIBLE
|
||||
}
|
||||
resetButton.setOnClickListener {
|
||||
AutofillMatcher.clearMatchesFor(this@AutofillPublisherChangedActivity, FormOrigin.App(appPackage))
|
||||
val fillResponse = intent.getParcelableExtra<FillResponse>(EXTRA_FILL_RESPONSE_AFTER_RESET)
|
||||
setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) })
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showPackageInfo() {
|
||||
runCatching {
|
||||
with(binding) {
|
||||
val packageInfo =
|
||||
packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
|
||||
val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
|
||||
warningAppInstallDate.text =
|
||||
getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
|
||||
val appInfo =
|
||||
packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
|
||||
warningAppName.text = "“${packageManager.getApplicationLabel(appInfo)}”"
|
||||
private fun showPackageInfo() {
|
||||
runCatching {
|
||||
with(binding) {
|
||||
val packageInfo = packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
|
||||
val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
|
||||
warningAppInstallDate.text = getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
|
||||
val appInfo = packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
|
||||
warningAppName.text = "“${packageManager.getApplicationLabel(appInfo)}”"
|
||||
|
||||
val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage)
|
||||
warningAppAdvancedInfo.text = getString(
|
||||
R.string.oreo_autofill_warning_publisher_advanced_info_template,
|
||||
appPackage,
|
||||
currentHash
|
||||
)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
e(e) { "Failed to retrieve package info for $appPackage" }
|
||||
finish()
|
||||
}
|
||||
val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage)
|
||||
warningAppAdvancedInfo.text =
|
||||
getString(R.string.oreo_autofill_warning_publisher_advanced_info_template, appPackage, currentHash)
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
e(e) { "Failed to retrieve package info for $appPackage" }
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,121 +29,106 @@ import java.io.File
|
|||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
class AutofillSaveActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_FOLDER_NAME =
|
||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME"
|
||||
private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD"
|
||||
private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME"
|
||||
private const val EXTRA_SHOULD_MATCH_APP =
|
||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP"
|
||||
private const val EXTRA_SHOULD_MATCH_WEB =
|
||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
|
||||
private const val EXTRA_GENERATE_PASSWORD =
|
||||
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
|
||||
private const val EXTRA_FOLDER_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME"
|
||||
private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD"
|
||||
private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME"
|
||||
private const val EXTRA_SHOULD_MATCH_APP = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP"
|
||||
private const val EXTRA_SHOULD_MATCH_WEB = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
|
||||
private const val EXTRA_GENERATE_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
|
||||
|
||||
private var saveRequestCode = 1
|
||||
private var saveRequestCode = 1
|
||||
|
||||
fun makeSaveIntentSender(
|
||||
context: Context,
|
||||
credentials: Credentials?,
|
||||
formOrigin: FormOrigin
|
||||
): IntentSender {
|
||||
val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
|
||||
// Prevent directory traversals
|
||||
val sanitizedIdentifier = identifier.replace('\\', '_')
|
||||
.replace('/', '_')
|
||||
.trimStart('.')
|
||||
.takeUnless { it.isBlank() } ?: formOrigin.identifier
|
||||
val directoryStructure = AutofillPreferences.directoryStructure(context)
|
||||
val folderName = directoryStructure.getSaveFolderName(
|
||||
sanitizedIdentifier = sanitizedIdentifier,
|
||||
username = credentials?.username
|
||||
fun makeSaveIntentSender(context: Context, credentials: Credentials?, formOrigin: FormOrigin): IntentSender {
|
||||
val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
|
||||
// Prevent directory traversals
|
||||
val sanitizedIdentifier =
|
||||
identifier.replace('\\', '_').replace('/', '_').trimStart('.').takeUnless { it.isBlank() }
|
||||
?: formOrigin.identifier
|
||||
val directoryStructure = AutofillPreferences.directoryStructure(context)
|
||||
val folderName =
|
||||
directoryStructure.getSaveFolderName(
|
||||
sanitizedIdentifier = sanitizedIdentifier,
|
||||
username = credentials?.username
|
||||
)
|
||||
val fileName = directoryStructure.getSaveFileName(username = credentials?.username, identifier = identifier)
|
||||
val intent =
|
||||
Intent(context, AutofillSaveActivity::class.java).apply {
|
||||
putExtras(
|
||||
bundleOf(
|
||||
EXTRA_FOLDER_NAME to folderName,
|
||||
EXTRA_NAME to fileName,
|
||||
EXTRA_PASSWORD to credentials?.password,
|
||||
EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
|
||||
EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
|
||||
EXTRA_GENERATE_PASSWORD to (credentials == null)
|
||||
)
|
||||
val fileName = directoryStructure.getSaveFileName(username = credentials?.username, identifier = identifier)
|
||||
val intent = Intent(context, AutofillSaveActivity::class.java).apply {
|
||||
putExtras(
|
||||
bundleOf(
|
||||
EXTRA_FOLDER_NAME to folderName,
|
||||
EXTRA_NAME to fileName,
|
||||
EXTRA_PASSWORD to credentials?.password,
|
||||
EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
|
||||
EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
|
||||
EXTRA_GENERATE_PASSWORD to (credentials == null)
|
||||
)
|
||||
)
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
saveRequestCode++,
|
||||
intent,
|
||||
PendingIntent.FLAG_CANCEL_CURRENT
|
||||
).intentSender
|
||||
)
|
||||
}
|
||||
return PendingIntent.getActivity(context, saveRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
|
||||
.intentSender
|
||||
}
|
||||
}
|
||||
|
||||
private val formOrigin by lazy(LazyThreadSafetyMode.NONE) {
|
||||
val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP)
|
||||
val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB)
|
||||
if (shouldMatchApp != null && shouldMatchWeb == null) {
|
||||
FormOrigin.App(shouldMatchApp)
|
||||
} else if (shouldMatchApp == null && shouldMatchWeb != null) {
|
||||
FormOrigin.Web(shouldMatchWeb)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
private val formOrigin by lazy(LazyThreadSafetyMode.NONE) {
|
||||
val shouldMatchApp: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_APP)
|
||||
val shouldMatchWeb: String? = intent.getStringExtra(EXTRA_SHOULD_MATCH_WEB)
|
||||
if (shouldMatchApp != null && shouldMatchWeb == null) {
|
||||
FormOrigin.App(shouldMatchApp)
|
||||
} else if (shouldMatchApp == null && shouldMatchWeb != null) {
|
||||
FormOrigin.Web(shouldMatchWeb)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val repo = PasswordRepository.getRepositoryDirectory()
|
||||
val saveIntent = Intent(this, PasswordCreationActivity::class.java).apply {
|
||||
putExtras(
|
||||
bundleOf(
|
||||
"REPO_PATH" to repo.absolutePath,
|
||||
"FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
|
||||
PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
|
||||
PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
|
||||
PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
|
||||
)
|
||||
)
|
||||
}
|
||||
registerForActivityResult(StartActivityForResult()) { result ->
|
||||
val data = result.data
|
||||
if (result.resultCode == RESULT_OK && data != null) {
|
||||
val createdPath = data.getStringExtra("CREATED_FILE")!!
|
||||
formOrigin?.let {
|
||||
AutofillMatcher.addMatchFor(this, it, File(createdPath))
|
||||
}
|
||||
val password = data.getStringExtra("PASSWORD")
|
||||
val resultIntent = if (password != null) {
|
||||
// Password was generated and should be filled into a form.
|
||||
val username = data.getStringExtra("USERNAME")
|
||||
val clientState =
|
||||
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run {
|
||||
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
|
||||
finish()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
val credentials = Credentials(username, password, null)
|
||||
val fillInDataset = AutofillResponseBuilder.makeFillInDataset(
|
||||
this,
|
||||
credentials,
|
||||
clientState,
|
||||
AutofillAction.Generate
|
||||
)
|
||||
Intent().apply {
|
||||
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
|
||||
}
|
||||
} else {
|
||||
// Password was extracted from a form, there is nothing to fill.
|
||||
Intent()
|
||||
}
|
||||
setResult(RESULT_OK, resultIntent)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val repo = PasswordRepository.getRepositoryDirectory()
|
||||
val saveIntent =
|
||||
Intent(this, PasswordCreationActivity::class.java).apply {
|
||||
putExtras(
|
||||
bundleOf(
|
||||
"REPO_PATH" to repo.absolutePath,
|
||||
"FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
|
||||
PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
|
||||
PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
|
||||
PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
|
||||
)
|
||||
)
|
||||
}
|
||||
registerForActivityResult(StartActivityForResult()) { result ->
|
||||
val data = result.data
|
||||
if (result.resultCode == RESULT_OK && data != null) {
|
||||
val createdPath = data.getStringExtra("CREATED_FILE")!!
|
||||
formOrigin?.let { AutofillMatcher.addMatchFor(this, it, File(createdPath)) }
|
||||
val password = data.getStringExtra("PASSWORD")
|
||||
val resultIntent =
|
||||
if (password != null) {
|
||||
// Password was generated and should be filled into a form.
|
||||
val username = data.getStringExtra("USERNAME")
|
||||
val clientState =
|
||||
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
|
||||
?: run {
|
||||
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
|
||||
finish()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
val credentials = Credentials(username, password, null)
|
||||
val fillInDataset =
|
||||
AutofillResponseBuilder.makeFillInDataset(this, credentials, clientState, AutofillAction.Generate)
|
||||
Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }
|
||||
} else {
|
||||
setResult(RESULT_CANCELED)
|
||||
// Password was extracted from a form, there is nothing to fill.
|
||||
Intent()
|
||||
}
|
||||
finish()
|
||||
}.launch(saveIntent)
|
||||
}
|
||||
setResult(RESULT_OK, resultIntent)
|
||||
} else {
|
||||
setResult(RESULT_CANCELED)
|
||||
}
|
||||
finish()
|
||||
}
|
||||
.launch(saveIntent)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,6 @@ import dev.msfjarvis.aps.R
|
|||
|
||||
class PasswordViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
|
||||
val title: TextView = itemView.findViewById(R.id.title)
|
||||
val subtitle: TextView = itemView.findViewById(R.id.subtitle)
|
||||
val title: TextView = itemView.findViewById(R.id.title)
|
||||
val subtitle: TextView = itemView.findViewById(R.id.subtitle)
|
||||
}
|
||||
|
|
|
@ -42,269 +42,249 @@ import org.openintents.openpgp.OpenPgpError
|
|||
@Suppress("Registered")
|
||||
open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
|
||||
|
||||
/**
|
||||
* Full path to the repository
|
||||
*/
|
||||
val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! }
|
||||
/** Full path to the repository */
|
||||
val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! }
|
||||
|
||||
/**
|
||||
* Full path to the password file being worked on
|
||||
*/
|
||||
val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! }
|
||||
/** Full path to the password file being worked on */
|
||||
val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! }
|
||||
|
||||
/**
|
||||
* Name of the password file
|
||||
*
|
||||
* Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
|
||||
*/
|
||||
val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension }
|
||||
/**
|
||||
* Name of the password file
|
||||
*
|
||||
* Converts personal/auth.foo.org/john_doe@example.org.gpg to john_doe.example.org
|
||||
*/
|
||||
val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension }
|
||||
|
||||
/**
|
||||
* Get the timestamp for when this file was last modified.
|
||||
*/
|
||||
val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) {
|
||||
getLastChangedString(
|
||||
intent.getLongExtra(
|
||||
"LAST_CHANGED_TIMESTAMP",
|
||||
-1L
|
||||
)
|
||||
)
|
||||
/** Get the timestamp for when this file was last modified. */
|
||||
val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) {
|
||||
getLastChangedString(intent.getLongExtra("LAST_CHANGED_TIMESTAMP", -1L))
|
||||
}
|
||||
|
||||
/** [SharedPreferences] instance used by subclasses to persist settings */
|
||||
val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs }
|
||||
|
||||
/**
|
||||
* Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain.
|
||||
*/
|
||||
private var serviceConnection: OpenPgpServiceConnection? = null
|
||||
var api: OpenPgpApi? = null
|
||||
|
||||
/**
|
||||
* A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with
|
||||
* in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package.
|
||||
*/
|
||||
private var previousListener: OpenPgpServiceConnection.OnBound? = null
|
||||
|
||||
/**
|
||||
* [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or
|
||||
* recent apps screen.
|
||||
*/
|
||||
@CallSuper
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
tag(TAG)
|
||||
}
|
||||
|
||||
/**
|
||||
* [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This is
|
||||
* annotated with [CallSuper] because it's critical to unbind the service to ensure we're not
|
||||
* leaking things.
|
||||
*/
|
||||
@CallSuper
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
serviceConnection?.unbindFromService()
|
||||
previousListener = null
|
||||
}
|
||||
|
||||
/**
|
||||
* [onResume] controls the flow for resumption of a PGP operation that was previously interrupted
|
||||
* by the [OPENPGP_PROVIDER] package being missing.
|
||||
*/
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
previousListener?.let { bindToOpenKeychain(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up [api] once the service is bound. Downstream consumers must call super this to
|
||||
* initialize [api]
|
||||
*/
|
||||
@CallSuper
|
||||
override fun onBound(service: IOpenPgpService2) {
|
||||
api = OpenPgpApi(this, service)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle
|
||||
* their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call
|
||||
* super.
|
||||
*/
|
||||
override fun onError(e: Exception) {
|
||||
e(e) { "Callers must handle their own exceptions" }
|
||||
throw e
|
||||
}
|
||||
|
||||
/** Method for subclasses to initiate binding with [OpenPgpServiceConnection]. */
|
||||
fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) {
|
||||
val installed =
|
||||
runCatching {
|
||||
packageManager.getPackageInfo(OPENPGP_PROVIDER, 0)
|
||||
true
|
||||
}
|
||||
.getOr(false)
|
||||
if (!installed) {
|
||||
previousListener = onBoundListener
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.openkeychain_not_installed_title))
|
||||
.setMessage(getString(R.string.openkeychain_not_installed_message))
|
||||
.setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ ->
|
||||
runCatching {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER))
|
||||
setPackage("com.android.vending")
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
.setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ ->
|
||||
runCatching {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER))
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
.setOnCancelListener { finish() }
|
||||
.show()
|
||||
return
|
||||
} else {
|
||||
previousListener = null
|
||||
serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { it.bindToService() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the case where OpenKeychain returns that it needs to interact with the user
|
||||
*
|
||||
* @param result The intent returned by OpenKeychain
|
||||
*/
|
||||
fun getUserInteractionRequestIntent(result: Intent): IntentSender {
|
||||
i { "RESULT_CODE_USER_INTERACTION_REQUIRED" }
|
||||
return result.getParcelableExtra<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender
|
||||
}
|
||||
|
||||
/** Gets a relative string describing when this shape was last changed (e.g. "one hour ago") */
|
||||
private fun getLastChangedString(timeStamp: Long): CharSequence {
|
||||
if (timeStamp < 0) {
|
||||
throw RuntimeException()
|
||||
}
|
||||
|
||||
/**
|
||||
* [SharedPreferences] instance used by subclasses to persist settings
|
||||
*/
|
||||
val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs }
|
||||
return DateUtils.getRelativeTimeSpanString(this, timeStamp, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle to the [OpenPgpApi] instance that is used by subclasses to interface with OpenKeychain.
|
||||
*/
|
||||
private var serviceConnection: OpenPgpServiceConnection? = null
|
||||
var api: OpenPgpApi? = null
|
||||
/**
|
||||
* Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses can
|
||||
* use this when they want to default to sane error handling.
|
||||
*/
|
||||
fun handleError(result: Intent) {
|
||||
val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR)
|
||||
if (error != null) {
|
||||
when (error.errorId) {
|
||||
OpenPgpError.NO_OR_WRONG_PASSPHRASE -> {
|
||||
snackbar(message = getString(R.string.openpgp_error_wrong_passphrase))
|
||||
}
|
||||
OpenPgpError.NO_USER_IDS -> {
|
||||
snackbar(message = getString(R.string.openpgp_error_no_user_ids))
|
||||
}
|
||||
else -> {
|
||||
snackbar(message = getString(R.string.openpgp_error_unknown, error.message))
|
||||
e { "onError getErrorId: ${error.errorId}" }
|
||||
e { "onError getMessage: ${error.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [OpenPgpServiceConnection.OnBound] instance for the last listener that we wish to bind with
|
||||
* in case the previous attempt was cancelled due to missing [OPENPGP_PROVIDER] package.
|
||||
*/
|
||||
private var previousListener: OpenPgpServiceConnection.OnBound? = null
|
||||
/**
|
||||
* Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing
|
||||
* [showSnackbar] as false.
|
||||
*/
|
||||
fun copyTextToClipboard(
|
||||
text: String?,
|
||||
showSnackbar: Boolean = true,
|
||||
@StringRes snackbarTextRes: Int = R.string.clipboard_copied_text
|
||||
) {
|
||||
val clipboard = clipboard ?: return
|
||||
val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (showSnackbar) {
|
||||
snackbar(message = resources.getString(snackbarTextRes))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots
|
||||
* or recent apps screen.
|
||||
*/
|
||||
@CallSuper
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
tag(TAG)
|
||||
/**
|
||||
* Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to hide
|
||||
* the default [Snackbar] and starts off an instance of [ClipboardService] to provide a way of
|
||||
* clearing the clipboard.
|
||||
*/
|
||||
fun copyPasswordToClipboard(password: String?) {
|
||||
copyTextToClipboard(password, showSnackbar = false)
|
||||
|
||||
val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45
|
||||
|
||||
if (clearAfter != 0) {
|
||||
val service =
|
||||
Intent(this, ClipboardService::class.java).apply {
|
||||
action = ClipboardService.ACTION_START
|
||||
putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(service)
|
||||
} else {
|
||||
startService(service)
|
||||
}
|
||||
snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter))
|
||||
} else {
|
||||
snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "APS/BasePgpActivity"
|
||||
const val KEY_PWGEN_TYPE_CLASSIC = "classic"
|
||||
const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
|
||||
|
||||
/** Gets the relative path to the repository */
|
||||
fun getRelativePath(fullPath: String, repositoryPath: String): String =
|
||||
fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
|
||||
|
||||
/** Gets the Parent path, relative to the repository */
|
||||
fun getParentPath(fullPath: String, repositoryPath: String): String {
|
||||
val relativePath = getRelativePath(fullPath, repositoryPath)
|
||||
val index = relativePath.lastIndexOf("/")
|
||||
return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/")
|
||||
}
|
||||
|
||||
/**
|
||||
* [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This
|
||||
* is annotated with [CallSuper] because it's critical to unbind the service to ensure we're not
|
||||
* leaking things.
|
||||
*/
|
||||
@CallSuper
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
serviceConnection?.unbindFromService()
|
||||
previousListener = null
|
||||
}
|
||||
|
||||
/**
|
||||
* [onResume] controls the flow for resumption of a PGP operation that was previously interrupted
|
||||
* by the [OPENPGP_PROVIDER] package being missing.
|
||||
*/
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
previousListener?.let { bindToOpenKeychain(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up [api] once the service is bound. Downstream consumers must call super this to
|
||||
* initialize [api]
|
||||
*/
|
||||
@CallSuper
|
||||
override fun onBound(service: IOpenPgpService2) {
|
||||
api = OpenPgpApi(this, service)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle
|
||||
* their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call super.
|
||||
*/
|
||||
override fun onError(e: Exception) {
|
||||
e(e) { "Callers must handle their own exceptions" }
|
||||
throw e
|
||||
}
|
||||
|
||||
/**
|
||||
* Method for subclasses to initiate binding with [OpenPgpServiceConnection].
|
||||
*/
|
||||
fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) {
|
||||
val installed = runCatching {
|
||||
packageManager.getPackageInfo(OPENPGP_PROVIDER, 0)
|
||||
true
|
||||
}.getOr(false)
|
||||
if (!installed) {
|
||||
previousListener = onBoundListener
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.openkeychain_not_installed_title))
|
||||
.setMessage(getString(R.string.openkeychain_not_installed_message))
|
||||
.setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ ->
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER))
|
||||
setPackage("com.android.vending")
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
.setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ ->
|
||||
runCatching {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER))
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
.setOnCancelListener { finish() }
|
||||
.show()
|
||||
return
|
||||
/** /path/to/store/social/facebook.gpg -> social/facebook */
|
||||
@JvmStatic
|
||||
fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
|
||||
var relativePath = getRelativePath(fullPath, repositoryPath)
|
||||
return if (relativePath.isNotEmpty() && relativePath != "/") {
|
||||
// remove preceding '/'
|
||||
relativePath = relativePath.substring(1)
|
||||
if (relativePath.endsWith('/')) {
|
||||
relativePath + basename
|
||||
} else {
|
||||
previousListener = null
|
||||
serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also {
|
||||
it.bindToService()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the case where OpenKeychain returns that it needs to interact with the user
|
||||
*
|
||||
* @param result The intent returned by OpenKeychain
|
||||
*/
|
||||
fun getUserInteractionRequestIntent(result: Intent): IntentSender {
|
||||
i { "RESULT_CODE_USER_INTERACTION_REQUIRED" }
|
||||
return result.getParcelableExtra<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a relative string describing when this shape was last changed
|
||||
* (e.g. "one hour ago")
|
||||
*/
|
||||
private fun getLastChangedString(timeStamp: Long): CharSequence {
|
||||
if (timeStamp < 0) {
|
||||
throw RuntimeException()
|
||||
}
|
||||
|
||||
return DateUtils.getRelativeTimeSpanString(this, timeStamp, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses
|
||||
* can use this when they want to default to sane error handling.
|
||||
*/
|
||||
fun handleError(result: Intent) {
|
||||
val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR)
|
||||
if (error != null) {
|
||||
when (error.errorId) {
|
||||
OpenPgpError.NO_OR_WRONG_PASSPHRASE -> {
|
||||
snackbar(message = getString(R.string.openpgp_error_wrong_passphrase))
|
||||
}
|
||||
OpenPgpError.NO_USER_IDS -> {
|
||||
snackbar(message = getString(R.string.openpgp_error_no_user_ids))
|
||||
}
|
||||
else -> {
|
||||
snackbar(message = getString(R.string.openpgp_error_unknown, error.message))
|
||||
e { "onError getErrorId: ${error.errorId}" }
|
||||
e { "onError getMessage: ${error.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies provided [text] to the clipboard. Shows a [Snackbar] which can be disabled by passing
|
||||
* [showSnackbar] as false.
|
||||
*/
|
||||
fun copyTextToClipboard(
|
||||
text: String?,
|
||||
showSnackbar: Boolean = true,
|
||||
@StringRes snackbarTextRes: Int = R.string.clipboard_copied_text
|
||||
) {
|
||||
val clipboard = clipboard ?: return
|
||||
val clip = ClipData.newPlainText("pgp_handler_result_pm", text)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (showSnackbar) {
|
||||
snackbar(message = resources.getString(snackbarTextRes))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to
|
||||
* hide the default [Snackbar] and starts off an instance of [ClipboardService] to provide a
|
||||
* way of clearing the clipboard.
|
||||
*/
|
||||
fun copyPasswordToClipboard(password: String?) {
|
||||
copyTextToClipboard(password, showSnackbar = false)
|
||||
|
||||
val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45
|
||||
|
||||
if (clearAfter != 0) {
|
||||
val service = Intent(this, ClipboardService::class.java).apply {
|
||||
action = ClipboardService.ACTION_START
|
||||
putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
startForegroundService(service)
|
||||
} else {
|
||||
startService(service)
|
||||
}
|
||||
snackbar(message = resources.getString(R.string.clipboard_password_toast_text, clearAfter))
|
||||
} else {
|
||||
snackbar(message = resources.getString(R.string.clipboard_password_no_clear_toast_text))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val TAG = "APS/BasePgpActivity"
|
||||
const val KEY_PWGEN_TYPE_CLASSIC = "classic"
|
||||
const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
|
||||
|
||||
/**
|
||||
* Gets the relative path to the repository
|
||||
*/
|
||||
fun getRelativePath(fullPath: String, repositoryPath: String): String =
|
||||
fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
|
||||
|
||||
/**
|
||||
* Gets the Parent path, relative to the repository
|
||||
*/
|
||||
fun getParentPath(fullPath: String, repositoryPath: String): String {
|
||||
val relativePath = getRelativePath(fullPath, repositoryPath)
|
||||
val index = relativePath.lastIndexOf("/")
|
||||
return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/")
|
||||
}
|
||||
|
||||
/**
|
||||
* /path/to/store/social/facebook.gpg -> social/facebook
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
|
||||
var relativePath = getRelativePath(fullPath, repositoryPath)
|
||||
return if (relativePath.isNotEmpty() && relativePath != "/") {
|
||||
// remove preceding '/'
|
||||
relativePath = relativePath.substring(1)
|
||||
if (relativePath.endsWith('/')) {
|
||||
relativePath + basename
|
||||
} else {
|
||||
"$relativePath/$basename"
|
||||
}
|
||||
} else {
|
||||
basename
|
||||
}
|
||||
"$relativePath/$basename"
|
||||
}
|
||||
} else {
|
||||
basename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,202 +37,196 @@ import org.openintents.openpgp.IOpenPgpService2
|
|||
|
||||
class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||
|
||||
private val binding by viewBinding(DecryptLayoutBinding::inflate)
|
||||
private val binding by viewBinding(DecryptLayoutBinding::inflate)
|
||||
|
||||
private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
|
||||
private var passwordEntry: PasswordEntry? = null
|
||||
private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
|
||||
private var passwordEntry: PasswordEntry? = null
|
||||
|
||||
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||
if (result.data == null) {
|
||||
setResult(RESULT_CANCELED, null)
|
||||
finish()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
when (result.resultCode) {
|
||||
RESULT_OK -> decryptAndVerify(result.data)
|
||||
RESULT_CANCELED -> {
|
||||
setResult(RESULT_CANCELED, result.data)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
bindToOpenKeychain(this)
|
||||
title = name
|
||||
with(binding) {
|
||||
setContentView(root)
|
||||
passwordCategory.text = relativeParentPath
|
||||
passwordFile.text = name
|
||||
passwordFile.setOnLongClickListener {
|
||||
copyTextToClipboard(name)
|
||||
true
|
||||
}
|
||||
passwordLastChanged.run {
|
||||
runCatching {
|
||||
text = resources.getString(R.string.last_changed, lastChangedString)
|
||||
}.onFailure {
|
||||
visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.pgp_handler, menu)
|
||||
passwordEntry?.let { entry ->
|
||||
if (menu != null) {
|
||||
menu.findItem(R.id.edit_password).isVisible = true
|
||||
if (entry.password.isNotEmpty()) {
|
||||
menu.findItem(R.id.share_password_as_plaintext).isVisible = true
|
||||
menu.findItem(R.id.copy_password).isVisible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> onBackPressed()
|
||||
R.id.edit_password -> editPassword()
|
||||
R.id.share_password_as_plaintext -> shareAsPlaintext()
|
||||
R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password)
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onBound(service: IOpenPgpService2) {
|
||||
super.onBound(service)
|
||||
decryptAndVerify()
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
e(e)
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically finishes the activity 60 seconds after decryption succeeded to prevent
|
||||
* information leaks from stale activities.
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private fun startAutoDismissTimer() {
|
||||
lifecycleScope.launch {
|
||||
delay(60.seconds)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the current password and hide all the fields populated by encrypted data so that when
|
||||
* the result triggers they can be repopulated with new data.
|
||||
*/
|
||||
private fun editPassword() {
|
||||
val intent = Intent(this, PasswordCreationActivity::class.java)
|
||||
intent.putExtra("FILE_PATH", relativeParentPath)
|
||||
intent.putExtra("REPO_PATH", repoPath)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
|
||||
startActivity(intent)
|
||||
private val userInteractionRequiredResult =
|
||||
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||
if (result.data == null) {
|
||||
setResult(RESULT_CANCELED, null)
|
||||
finish()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
when (result.resultCode) {
|
||||
RESULT_OK -> decryptAndVerify(result.data)
|
||||
RESULT_CANCELED -> {
|
||||
setResult(RESULT_CANCELED, result.data)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareAsPlaintext() {
|
||||
val sendIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
|
||||
type = "text/plain"
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
bindToOpenKeychain(this)
|
||||
title = name
|
||||
with(binding) {
|
||||
setContentView(root)
|
||||
passwordCategory.text = relativeParentPath
|
||||
passwordFile.text = name
|
||||
passwordFile.setOnLongClickListener {
|
||||
copyTextToClipboard(name)
|
||||
true
|
||||
}
|
||||
passwordLastChanged.run {
|
||||
runCatching { text = resources.getString(R.string.last_changed, lastChangedString) }.onFailure {
|
||||
visibility = View.GONE
|
||||
}
|
||||
// Always show a picker to give the user a chance to cancel
|
||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private fun decryptAndVerify(receivedIntent: Intent? = null) {
|
||||
if (api == null) {
|
||||
bindToOpenKeychain(this)
|
||||
return
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.pgp_handler, menu)
|
||||
passwordEntry?.let { entry ->
|
||||
if (menu != null) {
|
||||
menu.findItem(R.id.edit_password).isVisible = true
|
||||
if (entry.password.isNotEmpty()) {
|
||||
menu.findItem(R.id.share_password_as_plaintext).isVisible = true
|
||||
menu.findItem(R.id.copy_password).isVisible = true
|
||||
}
|
||||
val data = receivedIntent ?: Intent()
|
||||
data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
val inputStream = File(fullPath).inputStream()
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> onBackPressed()
|
||||
R.id.edit_password -> editPassword()
|
||||
R.id.share_password_as_plaintext -> shareAsPlaintext()
|
||||
R.id.copy_password -> copyPasswordToClipboard(passwordEntry?.password)
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
api?.executeApiAsync(data, inputStream, outputStream) { result ->
|
||||
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||
startAutoDismissTimer()
|
||||
runCatching {
|
||||
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
|
||||
val entry = PasswordEntry(outputStream)
|
||||
val items = arrayListOf<FieldItem>()
|
||||
val adapter = FieldItemAdapter(emptyList(), showPassword) { text ->
|
||||
copyTextToClipboard(text)
|
||||
}
|
||||
override fun onBound(service: IOpenPgpService2) {
|
||||
super.onBound(service)
|
||||
decryptAndVerify()
|
||||
}
|
||||
|
||||
if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
|
||||
copyPasswordToClipboard(entry.password)
|
||||
}
|
||||
override fun onError(e: Exception) {
|
||||
e(e)
|
||||
}
|
||||
|
||||
passwordEntry = entry
|
||||
invalidateOptionsMenu()
|
||||
/**
|
||||
* Automatically finishes the activity 60 seconds after decryption succeeded to prevent
|
||||
* information leaks from stale activities.
|
||||
*/
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private fun startAutoDismissTimer() {
|
||||
lifecycleScope.launch {
|
||||
delay(60.seconds)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.password.isNotEmpty()) {
|
||||
items.add(FieldItem.createPasswordField(entry.password))
|
||||
}
|
||||
/**
|
||||
* Edit the current password and hide all the fields populated by encrypted data so that when the
|
||||
* result triggers they can be repopulated with new data.
|
||||
*/
|
||||
private fun editPassword() {
|
||||
val intent = Intent(this, PasswordCreationActivity::class.java)
|
||||
intent.putExtra("FILE_PATH", relativeParentPath)
|
||||
intent.putExtra("REPO_PATH", repoPath)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContent)
|
||||
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
|
||||
startActivity(intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
if (entry.hasTotp()) {
|
||||
launch(Dispatchers.IO) {
|
||||
// Calculate the actual remaining time for the first pass
|
||||
// then return to the standard 30 second affair.
|
||||
val remainingTime =
|
||||
entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod)
|
||||
withContext(Dispatchers.Main) {
|
||||
val code = entry.calculateTotpCode() ?: "Error"
|
||||
items.add(FieldItem.createOtpField(code))
|
||||
}
|
||||
delay(remainingTime.seconds)
|
||||
repeat(Int.MAX_VALUE) {
|
||||
val code = entry.calculateTotpCode() ?: "Error"
|
||||
withContext(Dispatchers.Main) {
|
||||
adapter.updateOTPCode(code)
|
||||
}
|
||||
delay(30.seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun shareAsPlaintext() {
|
||||
val sendIntent =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
|
||||
type = "text/plain"
|
||||
}
|
||||
// Always show a picker to give the user a chance to cancel
|
||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)))
|
||||
}
|
||||
|
||||
if (!entry.username.isNullOrEmpty()) {
|
||||
items.add(FieldItem.createUsernameField(entry.username))
|
||||
}
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private fun decryptAndVerify(receivedIntent: Intent? = null) {
|
||||
if (api == null) {
|
||||
bindToOpenKeychain(this)
|
||||
return
|
||||
}
|
||||
val data = receivedIntent ?: Intent()
|
||||
data.action = OpenPgpApi.ACTION_DECRYPT_VERIFY
|
||||
|
||||
if (entry.hasExtraContentWithoutAuthData()) {
|
||||
entry.extraContentMap.forEach { (key, value) ->
|
||||
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
|
||||
}
|
||||
}
|
||||
val inputStream = File(fullPath).inputStream()
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
|
||||
binding.recyclerView.adapter = adapter
|
||||
adapter.updateItems(items)
|
||||
}.onFailure { e ->
|
||||
e(e)
|
||||
}
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val sender = getUserInteractionRequestIntent(result)
|
||||
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
api?.executeApiAsync(data, inputStream, outputStream) { result ->
|
||||
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||
startAutoDismissTimer()
|
||||
runCatching {
|
||||
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
|
||||
val entry = PasswordEntry(outputStream)
|
||||
val items = arrayListOf<FieldItem>()
|
||||
val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> copyTextToClipboard(text) }
|
||||
|
||||
if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
|
||||
copyPasswordToClipboard(entry.password)
|
||||
}
|
||||
|
||||
passwordEntry = entry
|
||||
invalidateOptionsMenu()
|
||||
|
||||
if (entry.password.isNotEmpty()) {
|
||||
items.add(FieldItem.createPasswordField(entry.password))
|
||||
}
|
||||
|
||||
if (entry.hasTotp()) {
|
||||
launch(Dispatchers.IO) {
|
||||
// Calculate the actual remaining time for the first pass
|
||||
// then return to the standard 30 second affair.
|
||||
val remainingTime = entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod)
|
||||
withContext(Dispatchers.Main) {
|
||||
val code = entry.calculateTotpCode() ?: "Error"
|
||||
items.add(FieldItem.createOtpField(code))
|
||||
}
|
||||
delay(remainingTime.seconds)
|
||||
repeat(Int.MAX_VALUE) {
|
||||
val code = entry.calculateTotpCode() ?: "Error"
|
||||
withContext(Dispatchers.Main) { adapter.updateOTPCode(code) }
|
||||
delay(30.seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry.username.isNullOrEmpty()) {
|
||||
items.add(FieldItem.createUsernameField(entry.username))
|
||||
}
|
||||
|
||||
if (entry.hasExtraContentWithoutAuthData()) {
|
||||
entry.extraContentMap.forEach { (key, value) ->
|
||||
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
|
||||
}
|
||||
}
|
||||
|
||||
binding.recyclerView.adapter = adapter
|
||||
adapter.updateItems(items)
|
||||
}
|
||||
.onFailure { e -> e(e) }
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val sender = getUserInteractionRequestIntent(result)
|
||||
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,56 +21,54 @@ import org.openintents.openpgp.IOpenPgpService2
|
|||
|
||||
class GetKeyIdsActivity : BasePgpActivity() {
|
||||
|
||||
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||
if (result.data == null || result.resultCode == RESULT_CANCELED) {
|
||||
setResult(RESULT_CANCELED, result.data)
|
||||
finish()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
getKeyIds(result.data!!)
|
||||
private val userInteractionRequiredResult =
|
||||
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||
if (result.data == null || result.resultCode == RESULT_CANCELED) {
|
||||
setResult(RESULT_CANCELED, result.data)
|
||||
finish()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
getKeyIds(result.data!!)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
bindToOpenKeychain(this)
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
bindToOpenKeychain(this)
|
||||
}
|
||||
|
||||
override fun onBound(service: IOpenPgpService2) {
|
||||
super.onBound(service)
|
||||
getKeyIds()
|
||||
}
|
||||
override fun onBound(service: IOpenPgpService2) {
|
||||
super.onBound(service)
|
||||
getKeyIds()
|
||||
}
|
||||
|
||||
override fun onError(e: Exception) {
|
||||
e(e)
|
||||
}
|
||||
override fun onError(e: Exception) {
|
||||
e(e)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Key ids from OpenKeychain
|
||||
*/
|
||||
private fun getKeyIds(data: Intent = Intent()) {
|
||||
data.action = OpenPgpApi.ACTION_GET_KEY_IDS
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
api?.executeApiAsync(data, null, null) { result ->
|
||||
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||
runCatching {
|
||||
val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map {
|
||||
OpenPgpUtils.convertKeyIdToHex(it)
|
||||
} ?: emptyList()
|
||||
val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray())
|
||||
setResult(RESULT_OK, keyResult)
|
||||
finish()
|
||||
}.onFailure { e ->
|
||||
e(e)
|
||||
}
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val sender = getUserInteractionRequestIntent(result)
|
||||
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
||||
}
|
||||
/** Get the Key ids from OpenKeychain */
|
||||
private fun getKeyIds(data: Intent = Intent()) {
|
||||
data.action = OpenPgpApi.ACTION_GET_KEY_IDS
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
api?.executeApiAsync(data, null, null) { result ->
|
||||
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||
runCatching {
|
||||
val ids =
|
||||
result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { OpenPgpUtils.convertKeyIdToHex(it) }
|
||||
?: emptyList()
|
||||
val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray())
|
||||
setResult(RESULT_OK, keyResult)
|
||||
finish()
|
||||
}
|
||||
.onFailure { e -> e(e) }
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val sender = getUserInteractionRequestIntent(result)
|
||||
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -55,454 +55,443 @@ import me.msfjarvis.openpgpktx.util.OpenPgpServiceConnection
|
|||
|
||||
class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||
|
||||
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
|
||||
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
|
||||
|
||||
private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
||||
private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
|
||||
private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
|
||||
private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) }
|
||||
private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) }
|
||||
private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
||||
private var oldCategory: String? = null
|
||||
private var copy: Boolean = false
|
||||
private var encryptionIntent: Intent = Intent()
|
||||
private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
||||
private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
|
||||
private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
|
||||
private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) {
|
||||
intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
|
||||
}
|
||||
private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, false) }
|
||||
private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
||||
private var oldCategory: String? = null
|
||||
private var copy: Boolean = false
|
||||
private var encryptionIntent: Intent = Intent()
|
||||
|
||||
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||
if (result.data == null) {
|
||||
setResult(RESULT_CANCELED, null)
|
||||
finish()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
when (result.resultCode) {
|
||||
RESULT_OK -> encrypt(result.data)
|
||||
RESULT_CANCELED -> {
|
||||
setResult(RESULT_CANCELED, result.data)
|
||||
finish()
|
||||
}
|
||||
private val userInteractionRequiredResult =
|
||||
registerForActivityResult(StartIntentSenderForResult()) { result ->
|
||||
if (result.data == null) {
|
||||
setResult(RESULT_CANCELED, null)
|
||||
finish()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
when (result.resultCode) {
|
||||
RESULT_OK -> encrypt(result.data)
|
||||
RESULT_CANCELED -> {
|
||||
setResult(RESULT_CANCELED, result.data)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val otpImportAction = registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
binding.otpImportButton.isVisible = false
|
||||
val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
|
||||
val contents = "${intentResult.contents}\n"
|
||||
private val otpImportAction =
|
||||
registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
binding.otpImportButton.isVisible = false
|
||||
val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
|
||||
val contents = "${intentResult.contents}\n"
|
||||
val currentExtras = binding.extraContent.text.toString()
|
||||
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') binding.extraContent.append("\n$contents")
|
||||
else binding.extraContent.append(contents)
|
||||
snackbar(message = getString(R.string.otp_import_success))
|
||||
} else {
|
||||
snackbar(message = getString(R.string.otp_import_failure))
|
||||
}
|
||||
}
|
||||
|
||||
private val gpgKeySelectAction =
|
||||
registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
||||
lifecycleScope.launch {
|
||||
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
|
||||
withContext(Dispatchers.IO) { gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) }
|
||||
commitChange(
|
||||
getString(
|
||||
R.string.git_commit_gpg_id,
|
||||
getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name)
|
||||
)
|
||||
)
|
||||
.onSuccess { encrypt(encryptionIntent) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.findTillRoot(fileName: String, rootPath: File): File? {
|
||||
val gpgFile = File(this, fileName)
|
||||
if (gpgFile.exists()) return gpgFile
|
||||
|
||||
if (this.absolutePath == rootPath.absolutePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
val parent = parentFile
|
||||
return if (parent != null && parent.exists()) {
|
||||
parent.findTillRoot(fileName, rootPath)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
bindToOpenKeychain(this)
|
||||
title = if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title)
|
||||
with(binding) {
|
||||
setContentView(root)
|
||||
generatePassword.setOnClickListener { generatePassword() }
|
||||
otpImportButton.setOnClickListener {
|
||||
supportFragmentManager.setFragmentResultListener(OTP_RESULT_REQUEST_KEY, this@PasswordCreationActivity) {
|
||||
requestKey,
|
||||
bundle ->
|
||||
if (requestKey == OTP_RESULT_REQUEST_KEY) {
|
||||
val contents = bundle.getString(RESULT)
|
||||
val currentExtras = binding.extraContent.text.toString()
|
||||
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
|
||||
binding.extraContent.append("\n$contents")
|
||||
else
|
||||
binding.extraContent.append(contents)
|
||||
snackbar(message = getString(R.string.otp_import_success))
|
||||
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') binding.extraContent.append("\n$contents")
|
||||
else binding.extraContent.append(contents)
|
||||
}
|
||||
}
|
||||
val items = arrayOf(getString(R.string.otp_import_qr_code), getString(R.string.otp_import_manual_entry))
|
||||
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
||||
.setItems(items) { _, index ->
|
||||
if (index == 0) {
|
||||
otpImportAction.launch(
|
||||
IntentIntegrator(this@PasswordCreationActivity)
|
||||
.setOrientationLocked(false)
|
||||
.setBeepEnabled(false)
|
||||
.setDesiredBarcodeFormats(QR_CODE)
|
||||
.createScanIntent()
|
||||
)
|
||||
} else if (index == 1) {
|
||||
OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
directoryInputLayout.apply {
|
||||
if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
|
||||
isEnabled = true
|
||||
} else {
|
||||
snackbar(message = getString(R.string.otp_import_failure))
|
||||
setBackgroundColor(getColor(android.R.color.transparent))
|
||||
}
|
||||
}
|
||||
|
||||
private val gpgKeySelectAction = registerForActivityResult(StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
||||
lifecycleScope.launch {
|
||||
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
|
||||
withContext(Dispatchers.IO) {
|
||||
gpgIdentifierFile.writeText((keyIds + "").joinToString("\n"))
|
||||
}
|
||||
commitChange(getString(
|
||||
R.string.git_commit_gpg_id,
|
||||
getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name)
|
||||
)).onSuccess {
|
||||
encrypt(encryptionIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
val path = getRelativePath(fullPath, repoPath)
|
||||
// Keep empty path field visible if it is editable.
|
||||
if (path.isEmpty() && !isEnabled) visibility = View.GONE
|
||||
else {
|
||||
directory.setText(path)
|
||||
oldCategory = path
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.findTillRoot(fileName: String, rootPath: File): File? {
|
||||
val gpgFile = File(this, fileName)
|
||||
if (gpgFile.exists()) return gpgFile
|
||||
|
||||
if (this.absolutePath == rootPath.absolutePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
val parent = parentFile
|
||||
return if (parent != null && parent.exists()) {
|
||||
parent.findTillRoot(fileName, rootPath)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
bindToOpenKeychain(this)
|
||||
title = if (editing)
|
||||
getString(R.string.edit_password)
|
||||
else
|
||||
getString(R.string.new_password_title)
|
||||
with(binding) {
|
||||
setContentView(root)
|
||||
generatePassword.setOnClickListener { generatePassword() }
|
||||
otpImportButton.setOnClickListener {
|
||||
supportFragmentManager.setFragmentResultListener(OTP_RESULT_REQUEST_KEY, this@PasswordCreationActivity) { requestKey, bundle ->
|
||||
if (requestKey == OTP_RESULT_REQUEST_KEY) {
|
||||
val contents = bundle.getString(RESULT)
|
||||
val currentExtras = binding.extraContent.text.toString()
|
||||
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
|
||||
binding.extraContent.append("\n$contents")
|
||||
else
|
||||
binding.extraContent.append(contents)
|
||||
}
|
||||
}
|
||||
val items = arrayOf(getString(R.string.otp_import_qr_code), getString(R.string.otp_import_manual_entry))
|
||||
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
||||
.setItems(items) { _, index ->
|
||||
if (index == 0) {
|
||||
otpImportAction.launch(IntentIntegrator(this@PasswordCreationActivity)
|
||||
.setOrientationLocked(false)
|
||||
.setBeepEnabled(false)
|
||||
.setDesiredBarcodeFormats(QR_CODE)
|
||||
.createScanIntent())
|
||||
} else if (index == 1) {
|
||||
OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
directoryInputLayout.apply {
|
||||
if (suggestedName != null || suggestedPass != null || shouldGeneratePassword) {
|
||||
isEnabled = true
|
||||
} else {
|
||||
setBackgroundColor(getColor(android.R.color.transparent))
|
||||
}
|
||||
val path = getRelativePath(fullPath, repoPath)
|
||||
// Keep empty path field visible if it is editable.
|
||||
if (path.isEmpty() && !isEnabled)
|
||||
visibility = View.GONE
|
||||
else {
|
||||
directory.setText(path)
|
||||
oldCategory = path
|
||||
}
|
||||
}
|
||||
if (suggestedName != null) {
|
||||
filename.setText(suggestedName)
|
||||
} else {
|
||||
filename.requestFocus()
|
||||
}
|
||||
// Allow the user to quickly switch between storing the username as the filename or
|
||||
// in the encrypted extras. This only makes sense if the directory structure is
|
||||
// FileBased.
|
||||
if (suggestedName == null &&
|
||||
AutofillPreferences.directoryStructure(this@PasswordCreationActivity) ==
|
||||
DirectoryStructure.FileBased
|
||||
) {
|
||||
encryptUsername.apply {
|
||||
visibility = View.VISIBLE
|
||||
setOnClickListener {
|
||||
if (isChecked) {
|
||||
// User wants to enable username encryption, so we add it to the
|
||||
// encrypted extras as the first line.
|
||||
val username = filename.text.toString()
|
||||
val extras = "username:$username\n${extraContent.text}"
|
||||
|
||||
filename.text?.clear()
|
||||
extraContent.setText(extras)
|
||||
} else {
|
||||
// User wants to disable username encryption, so we extract the
|
||||
// username from the encrypted extras and use it as the filename.
|
||||
val entry = PasswordEntry("PASSWORD\n${extraContent.text}")
|
||||
val username = entry.username
|
||||
|
||||
// username should not be null here by the logic in
|
||||
// updateViewState, but it could still happen due to
|
||||
// input lag.
|
||||
if (username != null) {
|
||||
filename.setText(username)
|
||||
extraContent.setText(entry.extraContentWithoutAuthData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
listOf(filename, extraContent).forEach {
|
||||
it.doOnTextChanged { _, _, _, _ -> updateViewState() }
|
||||
}
|
||||
}
|
||||
suggestedPass?.let {
|
||||
password.setText(it)
|
||||
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
suggestedExtra?.let { extraContent.setText(it) }
|
||||
if (shouldGeneratePassword) {
|
||||
generatePassword()
|
||||
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
}
|
||||
updateViewState()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
setResult(RESULT_CANCELED)
|
||||
onBackPressed()
|
||||
}
|
||||
R.id.save_password -> {
|
||||
copy = false
|
||||
encrypt()
|
||||
}
|
||||
R.id.save_and_copy_password -> {
|
||||
copy = true
|
||||
encrypt()
|
||||
}
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun generatePassword() {
|
||||
supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { requestKey, bundle ->
|
||||
if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
|
||||
binding.password.setText(bundle.getString(RESULT))
|
||||
}
|
||||
}
|
||||
when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
|
||||
KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment()
|
||||
.show(supportFragmentManager, "generator")
|
||||
KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment()
|
||||
.show(supportFragmentManager, "xkpwgenerator")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateViewState() = with(binding) {
|
||||
// Use PasswordEntry to parse extras for username
|
||||
val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
|
||||
}
|
||||
if (suggestedName != null) {
|
||||
filename.setText(suggestedName)
|
||||
} else {
|
||||
filename.requestFocus()
|
||||
}
|
||||
// Allow the user to quickly switch between storing the username as the filename or
|
||||
// in the encrypted extras. This only makes sense if the directory structure is
|
||||
// FileBased.
|
||||
if (suggestedName == null &&
|
||||
AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == DirectoryStructure.FileBased
|
||||
) {
|
||||
encryptUsername.apply {
|
||||
if (visibility != View.VISIBLE)
|
||||
return@apply
|
||||
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
||||
val hasUsernameInExtras = entry.hasUsername()
|
||||
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
|
||||
isChecked = hasUsernameInExtras
|
||||
visibility = View.VISIBLE
|
||||
setOnClickListener {
|
||||
if (isChecked) {
|
||||
// User wants to enable username encryption, so we add it to the
|
||||
// encrypted extras as the first line.
|
||||
val username = filename.text.toString()
|
||||
val extras = "username:$username\n${extraContent.text}"
|
||||
|
||||
filename.text?.clear()
|
||||
extraContent.setText(extras)
|
||||
} else {
|
||||
// User wants to disable username encryption, so we extract the
|
||||
// username from the encrypted extras and use it as the filename.
|
||||
val entry = PasswordEntry("PASSWORD\n${extraContent.text}")
|
||||
val username = entry.username
|
||||
|
||||
// username should not be null here by the logic in
|
||||
// updateViewState, but it could still happen due to
|
||||
// input lag.
|
||||
if (username != null) {
|
||||
filename.setText(username)
|
||||
extraContent.setText(entry.extraContentWithoutAuthData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
otpImportButton.isVisible = !entry.hasTotp()
|
||||
listOf(filename, extraContent).forEach { it.doOnTextChanged { _, _, _, _ -> updateViewState() } }
|
||||
}
|
||||
suggestedPass?.let {
|
||||
password.setText(it)
|
||||
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
suggestedExtra?.let { extraContent.setText(it) }
|
||||
if (shouldGeneratePassword) {
|
||||
generatePassword()
|
||||
password.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
}
|
||||
}
|
||||
updateViewState()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.pgp_handler_new_password, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
setResult(RESULT_CANCELED)
|
||||
onBackPressed()
|
||||
}
|
||||
R.id.save_password -> {
|
||||
copy = false
|
||||
encrypt()
|
||||
}
|
||||
R.id.save_and_copy_password -> {
|
||||
copy = true
|
||||
encrypt()
|
||||
}
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun generatePassword() {
|
||||
supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { requestKey, bundle ->
|
||||
if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
|
||||
binding.password.setText(bundle.getString(RESULT))
|
||||
}
|
||||
}
|
||||
when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
|
||||
KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
|
||||
KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment().show(supportFragmentManager, "xkpwgenerator")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateViewState() =
|
||||
with(binding) {
|
||||
// Use PasswordEntry to parse extras for username
|
||||
val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
|
||||
encryptUsername.apply {
|
||||
if (visibility != View.VISIBLE) return@apply
|
||||
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
||||
val hasUsernameInExtras = entry.hasUsername()
|
||||
isEnabled = hasUsernameInFileName xor hasUsernameInExtras
|
||||
isChecked = hasUsernameInExtras
|
||||
}
|
||||
otpImportButton.isVisible = !entry.hasTotp()
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts the password and the extra content
|
||||
*/
|
||||
private fun encrypt(receivedIntent: Intent? = null) {
|
||||
with(binding) {
|
||||
val editName = filename.text.toString().trim()
|
||||
val editPass = password.text.toString()
|
||||
val editExtra = extraContent.text.toString()
|
||||
/** Encrypts the password and the extra content */
|
||||
private fun encrypt(receivedIntent: Intent? = null) {
|
||||
with(binding) {
|
||||
val editName = filename.text.toString().trim()
|
||||
val editPass = password.text.toString()
|
||||
val editExtra = extraContent.text.toString()
|
||||
|
||||
if (editName.isEmpty()) {
|
||||
snackbar(message = resources.getString(R.string.file_toast_text))
|
||||
return@with
|
||||
} else if (editName.contains('/')) {
|
||||
snackbar(message = resources.getString(R.string.invalid_filename_text))
|
||||
return@with
|
||||
}
|
||||
if (editName.isEmpty()) {
|
||||
snackbar(message = resources.getString(R.string.file_toast_text))
|
||||
return@with
|
||||
} else if (editName.contains('/')) {
|
||||
snackbar(message = resources.getString(R.string.invalid_filename_text))
|
||||
return@with
|
||||
}
|
||||
|
||||
if (editPass.isEmpty() && editExtra.isEmpty()) {
|
||||
snackbar(message = resources.getString(R.string.empty_toast_text))
|
||||
return@with
|
||||
}
|
||||
if (editPass.isEmpty() && editExtra.isEmpty()) {
|
||||
snackbar(message = resources.getString(R.string.empty_toast_text))
|
||||
return@with
|
||||
}
|
||||
|
||||
if (copy) {
|
||||
copyPasswordToClipboard(editPass)
|
||||
}
|
||||
if (copy) {
|
||||
copyPasswordToClipboard(editPass)
|
||||
}
|
||||
|
||||
encryptionIntent = receivedIntent ?: Intent()
|
||||
encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT
|
||||
encryptionIntent = receivedIntent ?: Intent()
|
||||
encryptionIntent.action = OpenPgpApi.ACTION_ENCRYPT
|
||||
|
||||
// pass enters the key ID into `.gpg-id`.
|
||||
val repoRoot = PasswordRepository.getRepositoryDirectory()
|
||||
val gpgIdentifierFile = File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot)
|
||||
?: File(repoRoot, ".gpg-id").apply { createNewFile() }
|
||||
val gpgIdentifiers = gpgIdentifierFile.readLines()
|
||||
.filter { it.isNotBlank() }
|
||||
.map { line ->
|
||||
GpgIdentifier.fromString(line) ?: run {
|
||||
// The line being empty means this is most likely an empty `.gpg-id` file
|
||||
// we created. Skip the validation so we can make the user add a real ID.
|
||||
if (line.isEmpty()) return@run
|
||||
if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
|
||||
snackbar(message = resources.getString(R.string.short_key_ids_unsupported))
|
||||
} else {
|
||||
snackbar(message = resources.getString(R.string.invalid_gpg_id))
|
||||
}
|
||||
return@with
|
||||
}
|
||||
}
|
||||
if (gpgIdentifiers.isEmpty()) {
|
||||
gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java))
|
||||
return@with
|
||||
}
|
||||
val keyIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.KeyId>().map { it.id }.toLongArray()
|
||||
if (keyIds.isNotEmpty()) {
|
||||
encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds)
|
||||
}
|
||||
val userIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.UserId>().map { it.email }.toTypedArray()
|
||||
if (userIds.isNotEmpty()) {
|
||||
encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds)
|
||||
}
|
||||
|
||||
encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true)
|
||||
|
||||
val content = "$editPass\n$editExtra"
|
||||
val inputStream = ByteArrayInputStream(content.toByteArray())
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
|
||||
val path = when {
|
||||
// If we allowed the user to edit the relative path, we have to consider it here instead
|
||||
// of fullPath.
|
||||
directoryInputLayout.isEnabled -> {
|
||||
val editRelativePath = directory.text.toString().trim()
|
||||
if (editRelativePath.isEmpty()) {
|
||||
snackbar(message = resources.getString(R.string.path_toast_text))
|
||||
return
|
||||
}
|
||||
val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}")
|
||||
if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) {
|
||||
snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}")
|
||||
return
|
||||
}
|
||||
|
||||
"${passwordDirectory.path}/$editName.gpg"
|
||||
}
|
||||
else -> "$fullPath/$editName.gpg"
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
api?.executeApiAsync(encryptionIntent, inputStream, outputStream) { result ->
|
||||
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||
runCatching {
|
||||
val file = File(path)
|
||||
// If we're not editing, this file should not already exist!
|
||||
// Additionally, if we were editing and the incoming and outgoing
|
||||
// filenames differ, it means we renamed. Ensure that the target
|
||||
// doesn't already exist to prevent an accidental overwrite.
|
||||
if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists()) {
|
||||
snackbar(message = getString(R.string.password_creation_duplicate_error))
|
||||
return@executeApiAsync
|
||||
}
|
||||
|
||||
if (!file.isInsideRepository()) {
|
||||
snackbar(message = getString(R.string.message_error_destination_outside_repo))
|
||||
return@executeApiAsync
|
||||
}
|
||||
|
||||
file.outputStream().use {
|
||||
it.write(outputStream.toByteArray())
|
||||
}
|
||||
|
||||
//associate the new password name with the last name's timestamp in history
|
||||
val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
||||
val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64()
|
||||
val timestamp = preference.getString(oldFilePathHash)
|
||||
if (timestamp != null) {
|
||||
preference.edit {
|
||||
remove(oldFilePathHash)
|
||||
putString(file.absolutePath.base64(), timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
val returnIntent = Intent()
|
||||
returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
|
||||
returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
|
||||
returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName))
|
||||
|
||||
if (shouldGeneratePassword) {
|
||||
val directoryStructure =
|
||||
AutofillPreferences.directoryStructure(applicationContext)
|
||||
val entry = PasswordEntry(content)
|
||||
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
|
||||
val username = entry.username
|
||||
?: directoryStructure.getUsernameFor(file)
|
||||
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
|
||||
}
|
||||
|
||||
if (directoryInputLayout.isVisible && directoryInputLayout.isEnabled && oldFileName != null) {
|
||||
val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg")
|
||||
if (oldFile.path != file.path && !oldFile.delete()) {
|
||||
setResult(RESULT_CANCELED)
|
||||
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
||||
.setTitle(R.string.password_creation_file_fail_title)
|
||||
.setMessage(getString(R.string.password_creation_file_delete_fail_message, oldFileName))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
return@executeApiAsync
|
||||
}
|
||||
}
|
||||
|
||||
val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text
|
||||
lifecycleScope.launch {
|
||||
commitChange(resources.getString(
|
||||
commitMessageRes,
|
||||
getLongName(fullPath, repoPath, editName)
|
||||
)).onSuccess {
|
||||
setResult(RESULT_OK, returnIntent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
}.onFailure { e ->
|
||||
if (e is IOException) {
|
||||
e(e) { "Failed to write password file" }
|
||||
setResult(RESULT_CANCELED)
|
||||
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
||||
.setTitle(getString(R.string.password_creation_file_fail_title))
|
||||
.setMessage(getString(R.string.password_creation_file_write_fail_message))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val sender = getUserInteractionRequestIntent(result)
|
||||
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
||||
}
|
||||
}
|
||||
// pass enters the key ID into `.gpg-id`.
|
||||
val repoRoot = PasswordRepository.getRepositoryDirectory()
|
||||
val gpgIdentifierFile =
|
||||
File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot)
|
||||
?: File(repoRoot, ".gpg-id").apply { createNewFile() }
|
||||
val gpgIdentifiers =
|
||||
gpgIdentifierFile.readLines().filter { it.isNotBlank() }.map { line ->
|
||||
GpgIdentifier.fromString(line)
|
||||
?: run {
|
||||
// The line being empty means this is most likely an empty `.gpg-id`
|
||||
// file
|
||||
// we created. Skip the validation so we can make the user add a real
|
||||
// ID.
|
||||
if (line.isEmpty()) return@run
|
||||
if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
|
||||
snackbar(message = resources.getString(R.string.short_key_ids_unsupported))
|
||||
} else {
|
||||
snackbar(message = resources.getString(R.string.invalid_gpg_id))
|
||||
}
|
||||
return@with
|
||||
}
|
||||
}
|
||||
}
|
||||
if (gpgIdentifiers.isEmpty()) {
|
||||
gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java))
|
||||
return@with
|
||||
}
|
||||
val keyIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.KeyId>().map { it.id }.toLongArray()
|
||||
if (keyIds.isNotEmpty()) {
|
||||
encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds)
|
||||
}
|
||||
val userIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.UserId>().map { it.email }.toTypedArray()
|
||||
if (userIds.isNotEmpty()) {
|
||||
encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds)
|
||||
}
|
||||
|
||||
companion object {
|
||||
encryptionIntent.putExtra(OpenPgpApi.EXTRA_REQUEST_ASCII_ARMOR, true)
|
||||
|
||||
private const val KEY_PWGEN_TYPE_CLASSIC = "classic"
|
||||
private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
|
||||
const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
|
||||
const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
|
||||
const val RESULT = "RESULT"
|
||||
const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE"
|
||||
const val RETURN_EXTRA_NAME = "NAME"
|
||||
const val RETURN_EXTRA_LONG_NAME = "LONG_NAME"
|
||||
const val RETURN_EXTRA_USERNAME = "USERNAME"
|
||||
const val RETURN_EXTRA_PASSWORD = "PASSWORD"
|
||||
const val EXTRA_FILE_NAME = "FILENAME"
|
||||
const val EXTRA_PASSWORD = "PASSWORD"
|
||||
const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
|
||||
const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"
|
||||
const val EXTRA_EDITING = "EDITING"
|
||||
val content = "$editPass\n$editExtra"
|
||||
val inputStream = ByteArrayInputStream(content.toByteArray())
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
|
||||
val path =
|
||||
when {
|
||||
// If we allowed the user to edit the relative path, we have to consider it here
|
||||
// instead
|
||||
// of fullPath.
|
||||
directoryInputLayout.isEnabled -> {
|
||||
val editRelativePath = directory.text.toString().trim()
|
||||
if (editRelativePath.isEmpty()) {
|
||||
snackbar(message = resources.getString(R.string.path_toast_text))
|
||||
return
|
||||
}
|
||||
val passwordDirectory = File("$repoPath/${editRelativePath.trim('/')}")
|
||||
if (!passwordDirectory.exists() && !passwordDirectory.mkdir()) {
|
||||
snackbar(message = "Failed to create directory ${editRelativePath.trim('/')}")
|
||||
return
|
||||
}
|
||||
|
||||
"${passwordDirectory.path}/$editName.gpg"
|
||||
}
|
||||
else -> "$fullPath/$editName.gpg"
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
api?.executeApiAsync(encryptionIntent, inputStream, outputStream) { result ->
|
||||
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
|
||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||
runCatching {
|
||||
val file = File(path)
|
||||
// If we're not editing, this file should not already exist!
|
||||
// Additionally, if we were editing and the incoming and outgoing
|
||||
// filenames differ, it means we renamed. Ensure that the target
|
||||
// doesn't already exist to prevent an accidental overwrite.
|
||||
if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists()) {
|
||||
snackbar(message = getString(R.string.password_creation_duplicate_error))
|
||||
return@executeApiAsync
|
||||
}
|
||||
|
||||
if (!file.isInsideRepository()) {
|
||||
snackbar(message = getString(R.string.message_error_destination_outside_repo))
|
||||
return@executeApiAsync
|
||||
}
|
||||
|
||||
file.outputStream().use { it.write(outputStream.toByteArray()) }
|
||||
|
||||
// associate the new password name with the last name's timestamp in
|
||||
// history
|
||||
val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
||||
val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64()
|
||||
val timestamp = preference.getString(oldFilePathHash)
|
||||
if (timestamp != null) {
|
||||
preference.edit {
|
||||
remove(oldFilePathHash)
|
||||
putString(file.absolutePath.base64(), timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
val returnIntent = Intent()
|
||||
returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
|
||||
returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
|
||||
returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName))
|
||||
|
||||
if (shouldGeneratePassword) {
|
||||
val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
|
||||
val entry = PasswordEntry(content)
|
||||
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
|
||||
val username = entry.username ?: directoryStructure.getUsernameFor(file)
|
||||
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
|
||||
}
|
||||
|
||||
if (directoryInputLayout.isVisible && directoryInputLayout.isEnabled && oldFileName != null) {
|
||||
val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg")
|
||||
if (oldFile.path != file.path && !oldFile.delete()) {
|
||||
setResult(RESULT_CANCELED)
|
||||
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
||||
.setTitle(R.string.password_creation_file_fail_title)
|
||||
.setMessage(getString(R.string.password_creation_file_delete_fail_message, oldFileName))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
|
||||
.show()
|
||||
return@executeApiAsync
|
||||
}
|
||||
}
|
||||
|
||||
val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text
|
||||
lifecycleScope.launch {
|
||||
commitChange(resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName)))
|
||||
.onSuccess {
|
||||
setResult(RESULT_OK, returnIntent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
if (e is IOException) {
|
||||
e(e) { "Failed to write password file" }
|
||||
setResult(RESULT_CANCELED)
|
||||
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
||||
.setTitle(getString(R.string.password_creation_file_fail_title))
|
||||
.setMessage(getString(R.string.password_creation_file_write_fail_message))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
|
||||
.show()
|
||||
} else {
|
||||
e(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val sender = getUserInteractionRequestIntent(result)
|
||||
userInteractionRequiredResult.launch(IntentSenderRequest.Builder(sender).build())
|
||||
}
|
||||
OpenPgpApi.RESULT_CODE_ERROR -> handleError(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_PWGEN_TYPE_CLASSIC = "classic"
|
||||
private const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
|
||||
const val PASSWORD_RESULT_REQUEST_KEY = "PASSWORD_GENERATOR"
|
||||
const val OTP_RESULT_REQUEST_KEY = "OTP_IMPORT"
|
||||
const val RESULT = "RESULT"
|
||||
const val RETURN_EXTRA_CREATED_FILE = "CREATED_FILE"
|
||||
const val RETURN_EXTRA_NAME = "NAME"
|
||||
const val RETURN_EXTRA_LONG_NAME = "LONG_NAME"
|
||||
const val RETURN_EXTRA_USERNAME = "USERNAME"
|
||||
const val RETURN_EXTRA_PASSWORD = "PASSWORD"
|
||||
const val EXTRA_FILE_NAME = "FILENAME"
|
||||
const val EXTRA_PASSWORD = "PASSWORD"
|
||||
const val EXTRA_EXTRA_CONTENT = "EXTRA_CONTENT"
|
||||
const val EXTRA_GENERATE_PASSWORD = "GENERATE_PASSWORD"
|
||||
const val EXTRA_EDITING = "EDITING"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,138 +25,136 @@ import dev.msfjarvis.aps.util.extensions.resolveAttribute
|
|||
import dev.msfjarvis.aps.util.extensions.viewBinding
|
||||
|
||||
/**
|
||||
* [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like
|
||||
* API through [Builder] to create a similar UI, just at the bottom of the screen.
|
||||
* [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API
|
||||
* through [Builder] to create a similar UI, just at the bottom of the screen.
|
||||
*/
|
||||
class BasicBottomSheet private constructor(
|
||||
val title: String?,
|
||||
val message: String,
|
||||
val positiveButtonLabel: String?,
|
||||
val negativeButtonLabel: String?,
|
||||
val positiveButtonClickListener: View.OnClickListener?,
|
||||
val negativeButtonClickListener: View.OnClickListener?,
|
||||
class BasicBottomSheet
|
||||
private constructor(
|
||||
val title: String?,
|
||||
val message: String,
|
||||
val positiveButtonLabel: String?,
|
||||
val negativeButtonLabel: String?,
|
||||
val positiveButtonClickListener: View.OnClickListener?,
|
||||
val negativeButtonClickListener: View.OnClickListener?,
|
||||
) : BottomSheetDialogFragment() {
|
||||
|
||||
private val binding by viewBinding(BasicBottomSheetBinding::bind)
|
||||
private val binding by viewBinding(BasicBottomSheetBinding::bind)
|
||||
|
||||
private var behavior: BottomSheetBehavior<FrameLayout>? = null
|
||||
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||
private var behavior: BottomSheetBehavior<FrameLayout>? = null
|
||||
private val bottomSheetCallback =
|
||||
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
||||
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
dismiss()
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
if (savedInstanceState != null) dismiss()
|
||||
return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(
|
||||
object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
val dialog = dialog as BottomSheetDialog? ?: return
|
||||
behavior = dialog.behavior
|
||||
behavior?.apply {
|
||||
state = BottomSheetBehavior.STATE_EXPANDED
|
||||
peekHeight = 0
|
||||
addBottomSheetCallback(bottomSheetCallback)
|
||||
}
|
||||
if (!title.isNullOrEmpty()) {
|
||||
binding.bottomSheetTitle.isVisible = true
|
||||
binding.bottomSheetTitle.text = title
|
||||
}
|
||||
binding.bottomSheetMessage.text = message
|
||||
if (positiveButtonClickListener != null) {
|
||||
positiveButtonLabel?.let { buttonLbl -> binding.bottomSheetOkButton.text = buttonLbl }
|
||||
binding.bottomSheetOkButton.isVisible = true
|
||||
binding.bottomSheetOkButton.setOnClickListener {
|
||||
positiveButtonClickListener.onClick(it)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
if (savedInstanceState != null) dismiss()
|
||||
return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
val dialog = dialog as BottomSheetDialog? ?: return
|
||||
behavior = dialog.behavior
|
||||
behavior?.apply {
|
||||
state = BottomSheetBehavior.STATE_EXPANDED
|
||||
peekHeight = 0
|
||||
addBottomSheetCallback(bottomSheetCallback)
|
||||
}
|
||||
if (!title.isNullOrEmpty()) {
|
||||
binding.bottomSheetTitle.isVisible = true
|
||||
binding.bottomSheetTitle.text = title
|
||||
}
|
||||
binding.bottomSheetMessage.text = message
|
||||
if (positiveButtonClickListener != null) {
|
||||
positiveButtonLabel?.let { buttonLbl ->
|
||||
binding.bottomSheetOkButton.text = buttonLbl
|
||||
}
|
||||
binding.bottomSheetOkButton.isVisible = true
|
||||
binding.bottomSheetOkButton.setOnClickListener {
|
||||
positiveButtonClickListener.onClick(it)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
if (negativeButtonClickListener != null) {
|
||||
binding.bottomSheetCancelButton.isVisible = true
|
||||
negativeButtonLabel?.let { buttonLbl ->
|
||||
binding.bottomSheetCancelButton.text = buttonLbl
|
||||
}
|
||||
binding.bottomSheetCancelButton.setOnClickListener {
|
||||
negativeButtonClickListener.onClick(it)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (negativeButtonClickListener != null) {
|
||||
binding.bottomSheetCancelButton.isVisible = true
|
||||
negativeButtonLabel?.let { buttonLbl -> binding.bottomSheetCancelButton.text = buttonLbl }
|
||||
binding.bottomSheetCancelButton.setOnClickListener {
|
||||
negativeButtonClickListener.onClick(it)
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
val gradientDrawable = GradientDrawable().apply {
|
||||
setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
|
||||
}
|
||||
}
|
||||
view.background = gradientDrawable
|
||||
}
|
||||
)
|
||||
val gradientDrawable =
|
||||
GradientDrawable().apply { setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) }
|
||||
view.background = gradientDrawable
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss()
|
||||
behavior?.removeBottomSheetCallback(bottomSheetCallback)
|
||||
}
|
||||
|
||||
class Builder(val context: Context) {
|
||||
|
||||
private var title: String? = null
|
||||
private var message: String? = null
|
||||
private var positiveButtonLabel: String? = null
|
||||
private var negativeButtonLabel: String? = null
|
||||
private var positiveButtonClickListener: View.OnClickListener? = null
|
||||
private var negativeButtonClickListener: View.OnClickListener? = null
|
||||
|
||||
fun setTitleRes(@StringRes titleRes: Int): Builder {
|
||||
this.title = context.resources.getString(titleRes)
|
||||
return this
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss()
|
||||
behavior?.removeBottomSheetCallback(bottomSheetCallback)
|
||||
fun setTitle(title: String): Builder {
|
||||
this.title = title
|
||||
return this
|
||||
}
|
||||
|
||||
class Builder(val context: Context) {
|
||||
|
||||
private var title: String? = null
|
||||
private var message: String? = null
|
||||
private var positiveButtonLabel: String? = null
|
||||
private var negativeButtonLabel: String? = null
|
||||
private var positiveButtonClickListener: View.OnClickListener? = null
|
||||
private var negativeButtonClickListener: View.OnClickListener? = null
|
||||
|
||||
fun setTitleRes(@StringRes titleRes: Int): Builder {
|
||||
this.title = context.resources.getString(titleRes)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setTitle(title: String): Builder {
|
||||
this.title = title
|
||||
return this
|
||||
}
|
||||
|
||||
fun setMessageRes(@StringRes messageRes: Int): Builder {
|
||||
this.message = context.resources.getString(messageRes)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setMessage(message: String): Builder {
|
||||
this.message = message
|
||||
return this
|
||||
}
|
||||
|
||||
fun setPositiveButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
|
||||
this.positiveButtonClickListener = listener
|
||||
this.positiveButtonLabel = buttonLabel
|
||||
return this
|
||||
}
|
||||
|
||||
fun setNegativeButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
|
||||
this.negativeButtonClickListener = listener
|
||||
this.negativeButtonLabel = buttonLabel
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): BasicBottomSheet {
|
||||
require(message != null) { "Message needs to be set" }
|
||||
return BasicBottomSheet(
|
||||
title,
|
||||
message!!,
|
||||
positiveButtonLabel,
|
||||
negativeButtonLabel,
|
||||
positiveButtonClickListener,
|
||||
negativeButtonClickListener
|
||||
)
|
||||
}
|
||||
fun setMessageRes(@StringRes messageRes: Int): Builder {
|
||||
this.message = context.resources.getString(messageRes)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setMessage(message: String): Builder {
|
||||
this.message = message
|
||||
return this
|
||||
}
|
||||
|
||||
fun setPositiveButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
|
||||
this.positiveButtonClickListener = listener
|
||||
this.positiveButtonLabel = buttonLabel
|
||||
return this
|
||||
}
|
||||
|
||||
fun setNegativeButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
|
||||
this.negativeButtonClickListener = listener
|
||||
this.negativeButtonLabel = buttonLabel
|
||||
return this
|
||||
}
|
||||
|
||||
fun build(): BasicBottomSheet {
|
||||
require(message != null) { "Message needs to be set" }
|
||||
return BasicBottomSheet(
|
||||
title,
|
||||
message!!,
|
||||
positiveButtonLabel,
|
||||
negativeButtonLabel,
|
||||
positiveButtonClickListener,
|
||||
negativeButtonClickListener
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,77 +30,82 @@ import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
|||
|
||||
class FolderCreationDialogFragment : DialogFragment() {
|
||||
|
||||
private lateinit var newFolder: File
|
||||
private lateinit var newFolder: File
|
||||
|
||||
private val keySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
||||
val gpgIdentifierFile = File(newFolder, ".gpg-id")
|
||||
gpgIdentifierFile.writeText(keyIds.joinToString("\n"))
|
||||
val repo = PasswordRepository.getRepository(null)
|
||||
if (repo != null) {
|
||||
lifecycleScope.launch {
|
||||
val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath
|
||||
requireActivity().commitChange(
|
||||
getString(
|
||||
R.string.git_commit_gpg_id,
|
||||
BasePgpActivity.getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name)
|
||||
),
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
private val keySelectAction =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
||||
val gpgIdentifierFile = File(newFolder, ".gpg-id")
|
||||
gpgIdentifierFile.writeText(keyIds.joinToString("\n"))
|
||||
val repo = PasswordRepository.getRepository(null)
|
||||
if (repo != null) {
|
||||
lifecycleScope.launch {
|
||||
val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath
|
||||
requireActivity()
|
||||
.commitChange(
|
||||
getString(
|
||||
R.string.git_commit_gpg_id,
|
||||
BasePgpActivity.getLongName(
|
||||
gpgIdentifierFile.parentFile!!.absolutePath,
|
||||
repoPath,
|
||||
gpgIdentifierFile.name
|
||||
)
|
||||
),
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
|
||||
alertDialogBuilder.setTitle(R.string.title_create_folder)
|
||||
alertDialogBuilder.setView(R.layout.folder_dialog_fragment)
|
||||
alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null)
|
||||
alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ ->
|
||||
dismiss()
|
||||
}
|
||||
val dialog = alertDialogBuilder.create()
|
||||
dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
|
||||
dialog.setOnShowListener {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!)
|
||||
}
|
||||
}
|
||||
return dialog
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
|
||||
alertDialogBuilder.setTitle(R.string.title_create_folder)
|
||||
alertDialogBuilder.setView(R.layout.folder_dialog_fragment)
|
||||
alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null)
|
||||
alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> dismiss() }
|
||||
val dialog = alertDialogBuilder.create()
|
||||
dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
|
||||
dialog.setOnShowListener {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!)
|
||||
}
|
||||
}
|
||||
return dialog
|
||||
}
|
||||
|
||||
private fun createDirectory(currentDir: String) {
|
||||
val dialog = requireDialog()
|
||||
val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
|
||||
val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
|
||||
newFolder = File("$currentDir/${folderNameView.text}")
|
||||
folderNameViewContainer.error = when {
|
||||
newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
|
||||
newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
|
||||
else -> null
|
||||
}
|
||||
if (folderNameViewContainer.error != null) return
|
||||
newFolder.mkdirs()
|
||||
(requireActivity() as PasswordStore).refreshPasswordList(newFolder)
|
||||
if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
|
||||
keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
|
||||
return
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
private fun createDirectory(currentDir: String) {
|
||||
val dialog = requireDialog()
|
||||
val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
|
||||
val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
|
||||
newFolder = File("$currentDir/${folderNameView.text}")
|
||||
folderNameViewContainer.error =
|
||||
when {
|
||||
newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
|
||||
newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
|
||||
else -> null
|
||||
}
|
||||
if (folderNameViewContainer.error != null) return
|
||||
newFolder.mkdirs()
|
||||
(requireActivity() as PasswordStore).refreshPasswordList(newFolder)
|
||||
if (dialog.findViewById<MaterialCheckBox>(R.id.set_gpg_key).isChecked) {
|
||||
keySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
|
||||
return
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY"
|
||||
fun newInstance(startingDirectory: String): FolderCreationDialogFragment {
|
||||
val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory)
|
||||
val fragment = FolderCreationDialogFragment()
|
||||
fragment.arguments = extras
|
||||
return fragment
|
||||
}
|
||||
private const val CURRENT_DIR_EXTRA = "CURRENT_DIRECTORY"
|
||||
fun newInstance(startingDirectory: String): FolderCreationDialogFragment {
|
||||
val extras = bundleOf(CURRENT_DIR_EXTRA to startingDirectory)
|
||||
val fragment = FolderCreationDialogFragment()
|
||||
fragment.arguments = extras
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,53 +25,54 @@ import dev.msfjarvis.aps.util.extensions.resolveAttribute
|
|||
|
||||
class ItemCreationBottomSheet : BottomSheetDialogFragment() {
|
||||
|
||||
private var behavior: BottomSheetBehavior<FrameLayout>? = null
|
||||
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {
|
||||
private var behavior: BottomSheetBehavior<FrameLayout>? = null
|
||||
private val bottomSheetCallback =
|
||||
object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onSlide(bottomSheet: View, slideOffset: Float) {}
|
||||
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
|
||||
dismiss()
|
||||
}
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
if (savedInstanceState != null) dismiss()
|
||||
return inflater.inflate(R.layout.item_create_sheet, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(
|
||||
object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
val dialog = dialog as BottomSheetDialog? ?: return
|
||||
behavior = dialog.behavior
|
||||
behavior?.apply {
|
||||
state = BottomSheetBehavior.STATE_EXPANDED
|
||||
peekHeight = 0
|
||||
addBottomSheetCallback(bottomSheetCallback)
|
||||
}
|
||||
dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener {
|
||||
setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER))
|
||||
dismiss()
|
||||
}
|
||||
dialog.findViewById<View>(R.id.create_password)?.setOnClickListener {
|
||||
setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD))
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
val gradientDrawable =
|
||||
GradientDrawable().apply { setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) }
|
||||
view.background = gradientDrawable
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
if (savedInstanceState != null) dismiss()
|
||||
return inflater.inflate(R.layout.item_create_sheet, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
val dialog = dialog as BottomSheetDialog? ?: return
|
||||
behavior = dialog.behavior
|
||||
behavior?.apply {
|
||||
state = BottomSheetBehavior.STATE_EXPANDED
|
||||
peekHeight = 0
|
||||
addBottomSheetCallback(bottomSheetCallback)
|
||||
}
|
||||
dialog.findViewById<View>(R.id.create_folder)?.setOnClickListener {
|
||||
setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_FOLDER))
|
||||
dismiss()
|
||||
}
|
||||
dialog.findViewById<View>(R.id.create_password)?.setOnClickListener {
|
||||
setFragmentResult(ITEM_CREATION_REQUEST_KEY, bundleOf(ACTION_KEY to ACTION_PASSWORD))
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
})
|
||||
val gradientDrawable = GradientDrawable().apply {
|
||||
setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
|
||||
}
|
||||
view.background = gradientDrawable
|
||||
}
|
||||
|
||||
override fun dismiss() {
|
||||
super.dismiss()
|
||||
behavior?.removeBottomSheetCallback(bottomSheetCallback)
|
||||
}
|
||||
override fun dismiss() {
|
||||
super.dismiss()
|
||||
behavior?.removeBottomSheetCallback(bottomSheetCallback)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,32 +20,30 @@ import dev.msfjarvis.aps.util.extensions.requestInputFocusOnView
|
|||
|
||||
class OtpImportDialogFragment : DialogFragment() {
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater)
|
||||
builder.setView(binding.root)
|
||||
builder.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
setFragmentResult(
|
||||
PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
|
||||
bundleOf(
|
||||
PasswordCreationActivity.RESULT to getTOTPUri(binding)
|
||||
)
|
||||
)
|
||||
}
|
||||
val dialog = builder.create()
|
||||
dialog.requestInputFocusOnView<TextInputEditText>(R.id.secret)
|
||||
return dialog
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
val binding = FragmentManualOtpEntryBinding.inflate(layoutInflater)
|
||||
builder.setView(binding.root)
|
||||
builder.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
setFragmentResult(
|
||||
PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
|
||||
bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding))
|
||||
)
|
||||
}
|
||||
val dialog = builder.create()
|
||||
dialog.requestInputFocusOnView<TextInputEditText>(R.id.secret)
|
||||
return dialog
|
||||
}
|
||||
|
||||
private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String {
|
||||
val secret = binding.secret.text.toString()
|
||||
val account = binding.account.text.toString()
|
||||
if (secret.isBlank()) return ""
|
||||
val builder = Uri.Builder()
|
||||
builder.scheme("otpauth")
|
||||
builder.authority("totp")
|
||||
builder.appendQueryParameter("secret", secret)
|
||||
if (account.isNotBlank()) builder.appendQueryParameter("issuer", account)
|
||||
return builder.build().toString()
|
||||
}
|
||||
private fun getTOTPUri(binding: FragmentManualOtpEntryBinding): String {
|
||||
val secret = binding.secret.text.toString()
|
||||
val account = binding.account.text.toString()
|
||||
if (secret.isBlank()) return ""
|
||||
val builder = Uri.Builder()
|
||||
builder.scheme("otpauth")
|
||||
builder.authority("totp")
|
||||
builder.appendQueryParameter("secret", secret)
|
||||
if (account.isNotBlank()) builder.appendQueryParameter("issuer", account)
|
||||
return builder.build().toString()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,72 +31,70 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
|||
|
||||
class PasswordGeneratorDialogFragment : DialogFragment() {
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
val callingActivity = requireActivity()
|
||||
val binding = FragmentPwgenBinding.inflate(layoutInflater)
|
||||
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
|
||||
val prefs = requireActivity().applicationContext
|
||||
.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
val callingActivity = requireActivity()
|
||||
val binding = FragmentPwgenBinding.inflate(layoutInflater)
|
||||
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
|
||||
val prefs = requireActivity().applicationContext.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
||||
|
||||
builder.setView(binding.root)
|
||||
builder.setView(binding.root)
|
||||
|
||||
binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false)
|
||||
binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false)
|
||||
binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false)
|
||||
binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false)
|
||||
binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false)
|
||||
binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
|
||||
binding.numerals.isChecked = !prefs.getBoolean(PasswordOption.NoDigits.key, false)
|
||||
binding.symbols.isChecked = prefs.getBoolean(PasswordOption.AtLeastOneSymbol.key, false)
|
||||
binding.uppercase.isChecked = !prefs.getBoolean(PasswordOption.NoUppercaseLetters.key, false)
|
||||
binding.lowercase.isChecked = !prefs.getBoolean(PasswordOption.NoLowercaseLetters.key, false)
|
||||
binding.ambiguous.isChecked = !prefs.getBoolean(PasswordOption.NoAmbiguousCharacters.key, false)
|
||||
binding.pronounceable.isChecked = !prefs.getBoolean(PasswordOption.FullyRandom.key, true)
|
||||
|
||||
binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
|
||||
binding.passwordText.typeface = monoTypeface
|
||||
return builder.run {
|
||||
setTitle(R.string.pwgen_title)
|
||||
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||
setFragmentResult(
|
||||
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
|
||||
bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}")
|
||||
)
|
||||
}
|
||||
setNeutralButton(R.string.dialog_cancel) { _, _ -> }
|
||||
setNegativeButton(R.string.pwgen_generate, null)
|
||||
create()
|
||||
}.apply {
|
||||
setOnShowListener {
|
||||
generate(binding.passwordText)
|
||||
getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||||
generate(binding.passwordText)
|
||||
}
|
||||
}
|
||||
binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
|
||||
binding.passwordText.typeface = monoTypeface
|
||||
return builder
|
||||
.run {
|
||||
setTitle(R.string.pwgen_title)
|
||||
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||
setFragmentResult(
|
||||
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
|
||||
bundleOf(PasswordCreationActivity.RESULT to "${binding.passwordText.text}")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun generate(passwordField: AppCompatTextView) {
|
||||
setPreferences()
|
||||
passwordField.text = runCatching {
|
||||
generate(requireContext().applicationContext)
|
||||
}.getOrElse { e ->
|
||||
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
|
||||
""
|
||||
setNeutralButton(R.string.dialog_cancel) { _, _ -> }
|
||||
setNegativeButton(R.string.pwgen_generate, null)
|
||||
create()
|
||||
}
|
||||
.apply {
|
||||
setOnShowListener {
|
||||
generate(binding.passwordText)
|
||||
getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generate(binding.passwordText) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isChecked(@IdRes id: Int): Boolean {
|
||||
return requireDialog().findViewById<CheckBox>(id).isChecked
|
||||
}
|
||||
private fun generate(passwordField: AppCompatTextView) {
|
||||
setPreferences()
|
||||
passwordField.text =
|
||||
runCatching { generate(requireContext().applicationContext) }.getOrElse { e ->
|
||||
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPreferences() {
|
||||
val preferences = listOfNotNull(
|
||||
PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) },
|
||||
PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) },
|
||||
PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) },
|
||||
PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) },
|
||||
PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) },
|
||||
PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) }
|
||||
)
|
||||
val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString()
|
||||
val length = lengthText.toIntOrNull()?.takeIf { it >= 0 }
|
||||
?: PasswordGenerator.DEFAULT_LENGTH
|
||||
setPrefs(requireActivity().applicationContext, preferences, length)
|
||||
}
|
||||
private fun isChecked(@IdRes id: Int): Boolean {
|
||||
return requireDialog().findViewById<CheckBox>(id).isChecked
|
||||
}
|
||||
|
||||
private fun setPreferences() {
|
||||
val preferences =
|
||||
listOfNotNull(
|
||||
PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) },
|
||||
PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) },
|
||||
PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) },
|
||||
PasswordOption.NoAmbiguousCharacters.takeIf { !isChecked(R.id.ambiguous) },
|
||||
PasswordOption.FullyRandom.takeIf { !isChecked(R.id.pronounceable) },
|
||||
PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) }
|
||||
)
|
||||
val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString()
|
||||
val length = lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH
|
||||
setPrefs(requireActivity().applicationContext, preferences, length)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,103 +27,102 @@ import dev.msfjarvis.aps.util.extensions.getString
|
|||
import dev.msfjarvis.aps.util.pwgenxkpwd.CapsType
|
||||
import dev.msfjarvis.aps.util.pwgenxkpwd.PasswordBuilder
|
||||
|
||||
/** A placeholder fragment containing a simple view. */
|
||||
/** A placeholder fragment containing a simple view. */
|
||||
class XkPasswordGeneratorDialogFragment : DialogFragment() {
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
val callingActivity = requireActivity()
|
||||
val inflater = callingActivity.layoutInflater
|
||||
val binding = FragmentXkpwgenBinding.inflate(inflater)
|
||||
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
|
||||
val prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
val callingActivity = requireActivity()
|
||||
val inflater = callingActivity.layoutInflater
|
||||
val binding = FragmentXkpwgenBinding.inflate(inflater)
|
||||
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
|
||||
val prefs = callingActivity.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
||||
|
||||
builder.setView(binding.root)
|
||||
builder.setView(binding.root)
|
||||
|
||||
val previousStoredCapStyle: String = runCatching {
|
||||
prefs.getString(PREF_KEY_CAPITALS_STYLE)!!
|
||||
}.getOr(DEFAULT_CAPS_STYLE)
|
||||
val previousStoredCapStyle: String =
|
||||
runCatching { prefs.getString(PREF_KEY_CAPITALS_STYLE)!! }.getOr(DEFAULT_CAPS_STYLE)
|
||||
|
||||
val lastCapitalsStyleIndex: Int = runCatching {
|
||||
CapsType.valueOf(previousStoredCapStyle).ordinal
|
||||
}.getOr(DEFAULT_CAPS_INDEX)
|
||||
binding.xkCapType.setSelection(lastCapitalsStyleIndex)
|
||||
binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS))
|
||||
val lastCapitalsStyleIndex: Int =
|
||||
runCatching { CapsType.valueOf(previousStoredCapStyle).ordinal }.getOr(DEFAULT_CAPS_INDEX)
|
||||
binding.xkCapType.setSelection(lastCapitalsStyleIndex)
|
||||
binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS))
|
||||
|
||||
binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR))
|
||||
binding.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK))
|
||||
binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR))
|
||||
binding.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK))
|
||||
|
||||
binding.xkPasswordText.typeface = monoTypeface
|
||||
binding.xkPasswordText.typeface = monoTypeface
|
||||
|
||||
builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ ->
|
||||
setPreferences(binding, prefs)
|
||||
setFragmentResult(
|
||||
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
|
||||
bundleOf(PasswordCreationActivity.RESULT to "${binding.xkPasswordText.text}")
|
||||
)
|
||||
}
|
||||
|
||||
// flip neutral and negative buttons
|
||||
builder.setNeutralButton(resources.getString(R.string.dialog_cancel)) { _, _ -> }
|
||||
builder.setNegativeButton(resources.getString(R.string.pwgen_generate), null)
|
||||
|
||||
val dialog = builder.setTitle(this.resources.getString(R.string.xkpwgen_title)).create()
|
||||
|
||||
dialog.setOnShowListener {
|
||||
setPreferences(binding, prefs)
|
||||
makeAndSetPassword(binding)
|
||||
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||||
setPreferences(binding, prefs)
|
||||
makeAndSetPassword(binding)
|
||||
}
|
||||
}
|
||||
return dialog
|
||||
builder.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ ->
|
||||
setPreferences(binding, prefs)
|
||||
setFragmentResult(
|
||||
PasswordCreationActivity.PASSWORD_RESULT_REQUEST_KEY,
|
||||
bundleOf(PasswordCreationActivity.RESULT to "${binding.xkPasswordText.text}")
|
||||
)
|
||||
}
|
||||
|
||||
private fun makeAndSetPassword(binding: FragmentXkpwgenBinding) {
|
||||
PasswordBuilder(requireContext())
|
||||
.setNumberOfWords(Integer.valueOf(binding.xkNumWords.text.toString()))
|
||||
.setMinimumWordLength(DEFAULT_MIN_WORD_LENGTH)
|
||||
.setMaximumWordLength(DEFAULT_MAX_WORD_LENGTH)
|
||||
.setSeparator(binding.xkSeparator.text.toString())
|
||||
.appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT })
|
||||
.appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL })
|
||||
.setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString())).create()
|
||||
.fold(
|
||||
success = { binding.xkPasswordText.text = it },
|
||||
failure = { e ->
|
||||
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
|
||||
tag("xkpw").e(e, "failure generating xkpasswd")
|
||||
binding.xkPasswordText.text = FALLBACK_ERROR_PASS
|
||||
},
|
||||
)
|
||||
}
|
||||
// flip neutral and negative buttons
|
||||
builder.setNeutralButton(resources.getString(R.string.dialog_cancel)) { _, _ -> }
|
||||
builder.setNegativeButton(resources.getString(R.string.pwgen_generate), null)
|
||||
|
||||
private fun setPreferences(binding: FragmentXkpwgenBinding, prefs: SharedPreferences) {
|
||||
prefs.edit {
|
||||
putString(PREF_KEY_CAPITALS_STYLE, binding.xkCapType.selectedItem.toString())
|
||||
putString(PREF_KEY_NUM_WORDS, binding.xkNumWords.text.toString())
|
||||
putString(PREF_KEY_SEPARATOR, binding.xkSeparator.text.toString())
|
||||
putString(PREF_KEY_EXTRA_SYMBOLS_MASK, binding.xkNumberSymbolMask.text.toString())
|
||||
}
|
||||
}
|
||||
val dialog = builder.setTitle(this.resources.getString(R.string.xkpwgen_title)).create()
|
||||
|
||||
companion object {
|
||||
dialog.setOnShowListener {
|
||||
setPreferences(binding, prefs)
|
||||
makeAndSetPassword(binding)
|
||||
|
||||
const val PREF_KEY_CAPITALS_STYLE = "pref_key_capitals_style"
|
||||
const val PREF_KEY_NUM_WORDS = "pref_key_num_words"
|
||||
const val PREF_KEY_SEPARATOR = "pref_key_separator"
|
||||
const val PREF_KEY_EXTRA_SYMBOLS_MASK = "pref_key_xkpwgen_extra_symbols_mask"
|
||||
val DEFAULT_CAPS_STYLE = CapsType.Sentence.name
|
||||
val DEFAULT_CAPS_INDEX = CapsType.Sentence.ordinal
|
||||
const val DEFAULT_NUMBER_OF_WORDS = "3"
|
||||
const val DEFAULT_WORD_SEPARATOR = "."
|
||||
const val DEFAULT_EXTRA_SYMBOLS_MASK = "ds"
|
||||
const val DEFAULT_MIN_WORD_LENGTH = 3
|
||||
const val DEFAULT_MAX_WORD_LENGTH = 9
|
||||
const val FALLBACK_ERROR_PASS = "42"
|
||||
const val EXTRA_CHAR_PLACEHOLDER_DIGIT = 'd'
|
||||
const val EXTRA_CHAR_PLACEHOLDER_SYMBOL = 's'
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||||
setPreferences(binding, prefs)
|
||||
makeAndSetPassword(binding)
|
||||
}
|
||||
}
|
||||
return dialog
|
||||
}
|
||||
|
||||
private fun makeAndSetPassword(binding: FragmentXkpwgenBinding) {
|
||||
PasswordBuilder(requireContext())
|
||||
.setNumberOfWords(Integer.valueOf(binding.xkNumWords.text.toString()))
|
||||
.setMinimumWordLength(DEFAULT_MIN_WORD_LENGTH)
|
||||
.setMaximumWordLength(DEFAULT_MAX_WORD_LENGTH)
|
||||
.setSeparator(binding.xkSeparator.text.toString())
|
||||
.appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT })
|
||||
.appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL })
|
||||
.setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString()))
|
||||
.create()
|
||||
.fold(
|
||||
success = { binding.xkPasswordText.text = it },
|
||||
failure = { e ->
|
||||
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
|
||||
tag("xkpw").e(e, "failure generating xkpasswd")
|
||||
binding.xkPasswordText.text = FALLBACK_ERROR_PASS
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun setPreferences(binding: FragmentXkpwgenBinding, prefs: SharedPreferences) {
|
||||
prefs.edit {
|
||||
putString(PREF_KEY_CAPITALS_STYLE, binding.xkCapType.selectedItem.toString())
|
||||
putString(PREF_KEY_NUM_WORDS, binding.xkNumWords.text.toString())
|
||||
putString(PREF_KEY_SEPARATOR, binding.xkSeparator.text.toString())
|
||||
putString(PREF_KEY_EXTRA_SYMBOLS_MASK, binding.xkNumberSymbolMask.text.toString())
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val PREF_KEY_CAPITALS_STYLE = "pref_key_capitals_style"
|
||||
const val PREF_KEY_NUM_WORDS = "pref_key_num_words"
|
||||
const val PREF_KEY_SEPARATOR = "pref_key_separator"
|
||||
const val PREF_KEY_EXTRA_SYMBOLS_MASK = "pref_key_xkpwgen_extra_symbols_mask"
|
||||
val DEFAULT_CAPS_STYLE = CapsType.Sentence.name
|
||||
val DEFAULT_CAPS_INDEX = CapsType.Sentence.ordinal
|
||||
const val DEFAULT_NUMBER_OF_WORDS = "3"
|
||||
const val DEFAULT_WORD_SEPARATOR = "."
|
||||
const val DEFAULT_EXTRA_SYMBOLS_MASK = "ds"
|
||||
const val DEFAULT_MIN_WORD_LENGTH = 3
|
||||
const val DEFAULT_MAX_WORD_LENGTH = 9
|
||||
const val FALLBACK_ERROR_PASS = "42"
|
||||
const val EXTRA_CHAR_PLACEHOLDER_DIGIT = 'd'
|
||||
const val EXTRA_CHAR_PLACEHOLDER_SYMBOL = 's'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,49 +15,46 @@ import dev.msfjarvis.aps.data.repo.PasswordRepository
|
|||
import dev.msfjarvis.aps.ui.passwords.PASSWORD_FRAGMENT_TAG
|
||||
import dev.msfjarvis.aps.ui.passwords.PasswordStore
|
||||
|
||||
|
||||
class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
|
||||
|
||||
private lateinit var passwordList: SelectFolderFragment
|
||||
private lateinit var passwordList: SelectFolderFragment
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
passwordList = SelectFolderFragment()
|
||||
val args = Bundle()
|
||||
args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
|
||||
passwordList = SelectFolderFragment()
|
||||
val args = Bundle()
|
||||
args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
|
||||
|
||||
passwordList.arguments = args
|
||||
passwordList.arguments = args
|
||||
|
||||
supportActionBar?.show()
|
||||
supportActionBar?.show()
|
||||
|
||||
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
|
||||
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
|
||||
|
||||
supportFragmentManager.commit {
|
||||
replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG)
|
||||
}
|
||||
supportFragmentManager.commit { replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) }
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.pgp_handler_select_folder, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
setResult(RESULT_CANCELED)
|
||||
onBackPressed()
|
||||
}
|
||||
R.id.crypto_select -> selectFolder()
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.pgp_handler_select_folder, menu)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
setResult(RESULT_CANCELED)
|
||||
onBackPressed()
|
||||
}
|
||||
R.id.crypto_select -> selectFolder()
|
||||
else -> return super.onOptionsItemSelected(item)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun selectFolder() {
|
||||
intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
|
||||
setResult(RESULT_OK, intent)
|
||||
finish()
|
||||
}
|
||||
private fun selectFolder() {
|
||||
intent.putExtra("SELECTED_FOLDER_PATH", passwordList.currentDir.absolutePath)
|
||||
setResult(RESULT_OK, intent)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,56 +26,51 @@ import me.zhanghai.android.fastscroll.FastScrollerBuilder
|
|||
|
||||
class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
|
||||
|
||||
private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
|
||||
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
|
||||
private lateinit var listener: OnFragmentInteractionListener
|
||||
private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
|
||||
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
|
||||
private lateinit var listener: OnFragmentInteractionListener
|
||||
|
||||
private val model: SearchableRepositoryViewModel by activityViewModels()
|
||||
private val model: SearchableRepositoryViewModel by activityViewModels()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.fab.hide()
|
||||
recyclerAdapter = PasswordItemRecyclerAdapter()
|
||||
.onItemClicked { _, item ->
|
||||
listener.onFragmentInteraction(item)
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.fab.hide()
|
||||
recyclerAdapter = PasswordItemRecyclerAdapter().onItemClicked { _, item -> listener.onFragmentInteraction(item) }
|
||||
binding.passRecycler.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
itemAnimator = null
|
||||
adapter = recyclerAdapter
|
||||
}
|
||||
|
||||
FastScrollerBuilder(binding.passRecycler).build()
|
||||
registerForContextMenu(binding.passRecycler)
|
||||
|
||||
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
|
||||
model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
|
||||
model.searchResult.observe(viewLifecycleOwner) { result -> recyclerAdapter.submitList(result.passwordItems) }
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
runCatching {
|
||||
listener =
|
||||
object : OnFragmentInteractionListener {
|
||||
override fun onFragmentInteraction(item: PasswordItem) {
|
||||
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
||||
model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
|
||||
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
binding.passRecycler.apply {
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
itemAnimator = null
|
||||
adapter = recyclerAdapter
|
||||
}
|
||||
|
||||
FastScrollerBuilder(binding.passRecycler).build()
|
||||
registerForContextMenu(binding.passRecycler)
|
||||
|
||||
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
|
||||
model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
|
||||
model.searchResult.observe(viewLifecycleOwner) { result ->
|
||||
recyclerAdapter.submitList(result.passwordItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { throw ClassCastException("$context must implement OnFragmentInteractionListener") }
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
runCatching {
|
||||
listener = object : OnFragmentInteractionListener {
|
||||
override fun onFragmentInteraction(item: PasswordItem) {
|
||||
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
||||
model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
|
||||
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
throw ClassCastException("$context must implement OnFragmentInteractionListener")
|
||||
}
|
||||
}
|
||||
val currentDir: File
|
||||
get() = model.currentDir.value!!
|
||||
|
||||
val currentDir: File
|
||||
get() = model.currentDir.value!!
|
||||
interface OnFragmentInteractionListener {
|
||||
|
||||
interface OnFragmentInteractionListener {
|
||||
|
||||
fun onFragmentInteraction(item: PasswordItem)
|
||||
}
|
||||
fun onFragmentInteraction(item: PasswordItem)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,132 +33,130 @@ import net.schmizz.sshj.transport.TransportException
|
|||
import net.schmizz.sshj.userauth.UserAuthException
|
||||
|
||||
/**
|
||||
* Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related
|
||||
* tasks and makes sense to be held here.
|
||||
* Abstract [AppCompatActivity] that holds some information that is commonly shared across
|
||||
* git-related tasks and makes sense to be held here.
|
||||
*/
|
||||
abstract class BaseGitActivity : ContinuationContainerActivity() {
|
||||
|
||||
/**
|
||||
* Enum of possible Git operations than can be run through [launchGitOperation].
|
||||
*/
|
||||
enum class GitOp {
|
||||
/** Enum of possible Git operations than can be run through [launchGitOperation]. */
|
||||
enum class GitOp {
|
||||
BREAK_OUT_OF_DETACHED,
|
||||
CLONE,
|
||||
PULL,
|
||||
PUSH,
|
||||
RESET,
|
||||
SYNC,
|
||||
}
|
||||
|
||||
BREAK_OUT_OF_DETACHED,
|
||||
CLONE,
|
||||
PULL,
|
||||
PUSH,
|
||||
RESET,
|
||||
SYNC,
|
||||
/**
|
||||
* Attempt to launch the requested Git operation.
|
||||
* @param operation The type of git operation to launch
|
||||
*/
|
||||
suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> {
|
||||
if (GitSettings.url == null) {
|
||||
return Err(IllegalStateException("Git url is not set!"))
|
||||
}
|
||||
if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) {
|
||||
// If the server does not support multiple SSH channels per connection, we cannot run
|
||||
// a sync operation without reconnecting and thus break sync into its two parts.
|
||||
return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) }
|
||||
}
|
||||
val op =
|
||||
when (operation) {
|
||||
GitOp.CLONE -> CloneOperation(this, GitSettings.url!!)
|
||||
GitOp.PULL -> PullOperation(this, GitSettings.rebaseOnPull)
|
||||
GitOp.PUSH -> PushOperation(this)
|
||||
GitOp.SYNC -> SyncOperation(this, GitSettings.rebaseOnPull)
|
||||
GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this)
|
||||
GitOp.RESET -> ResetToRemoteOperation(this)
|
||||
}
|
||||
return op.executeAfterAuthentication(GitSettings.authMode).mapError(::transformGitError)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to launch the requested Git operation.
|
||||
* @param operation The type of git operation to launch
|
||||
*/
|
||||
suspend fun launchGitOperation(operation: GitOp): Result<Unit, Throwable> {
|
||||
if (GitSettings.url == null) {
|
||||
return Err(IllegalStateException("Git url is not set!"))
|
||||
}
|
||||
if (operation == GitOp.SYNC && !GitSettings.useMultiplexing) {
|
||||
// If the server does not support multiple SSH channels per connection, we cannot run
|
||||
// a sync operation without reconnecting and thus break sync into its two parts.
|
||||
return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) }
|
||||
}
|
||||
val op = when (operation) {
|
||||
GitOp.CLONE -> CloneOperation(this, GitSettings.url!!)
|
||||
GitOp.PULL -> PullOperation(this, GitSettings.rebaseOnPull)
|
||||
GitOp.PUSH -> PushOperation(this)
|
||||
GitOp.SYNC -> SyncOperation(this, GitSettings.rebaseOnPull)
|
||||
GitOp.BREAK_OUT_OF_DETACHED -> BreakOutOfDetached(this)
|
||||
GitOp.RESET -> ResetToRemoteOperation(this)
|
||||
}
|
||||
return op.executeAfterAuthentication(GitSettings.authMode).mapError(::transformGitError)
|
||||
}
|
||||
fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) {
|
||||
finish()
|
||||
}
|
||||
|
||||
fun finishOnSuccessHandler(@Suppress("UNUSED_PARAMETER") nothing: Unit) {
|
||||
finish()
|
||||
}
|
||||
|
||||
suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
|
||||
val error = rootCauseException(err)
|
||||
if (!isExplicitlyUserInitiatedError(error)) {
|
||||
getEncryptedGitPrefs().edit {
|
||||
remove(PreferenceKeys.HTTPS_PASSWORD)
|
||||
}
|
||||
sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
|
||||
d(error)
|
||||
withContext(Dispatchers.Main) {
|
||||
MaterialAlertDialogBuilder(this@BaseGitActivity).run {
|
||||
setTitle(resources.getString(R.string.jgit_error_dialog_title))
|
||||
setMessage(ErrorMessages[error])
|
||||
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
|
||||
setOnDismissListener {
|
||||
onPromptDone()
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onPromptDone()
|
||||
suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
|
||||
val error = rootCauseException(err)
|
||||
if (!isExplicitlyUserInitiatedError(error)) {
|
||||
getEncryptedGitPrefs().edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
|
||||
sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
|
||||
d(error)
|
||||
withContext(Dispatchers.Main) {
|
||||
MaterialAlertDialogBuilder(this@BaseGitActivity).run {
|
||||
setTitle(resources.getString(R.string.jgit_error_dialog_title))
|
||||
setMessage(ErrorMessages[error])
|
||||
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
|
||||
setOnDismissListener { onPromptDone() }
|
||||
show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onPromptDone()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the result of [launchGitOperation] and applies any necessary transformations
|
||||
* on the [throwable] returned from it
|
||||
*/
|
||||
private fun transformGitError(throwable: Throwable): Throwable {
|
||||
val err = rootCauseException(throwable)
|
||||
return when {
|
||||
err.message?.contains("cannot open additional channels") == true -> {
|
||||
GitSettings.useMultiplexing = false
|
||||
SSHException(DisconnectReason.TOO_MANY_CONNECTIONS, "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used.")
|
||||
}
|
||||
err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> {
|
||||
IllegalStateException("Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings")
|
||||
}
|
||||
err is TransportException && err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> {
|
||||
SSHException(DisconnectReason.HOST_KEY_NOT_VERIFIABLE,
|
||||
"WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
err
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Takes the result of [launchGitOperation] and applies any necessary transformations on the
|
||||
* [throwable] returned from it
|
||||
*/
|
||||
private fun transformGitError(throwable: Throwable): Throwable {
|
||||
val err = rootCauseException(throwable)
|
||||
return when {
|
||||
err.message?.contains("cannot open additional channels") == true -> {
|
||||
GitSettings.useMultiplexing = false
|
||||
SSHException(
|
||||
DisconnectReason.TOO_MANY_CONNECTIONS,
|
||||
"The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used."
|
||||
)
|
||||
}
|
||||
err.message?.contains("int org.eclipse.jgit.lib.AnyObjectId.w1") == true -> {
|
||||
IllegalStateException(
|
||||
"Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings"
|
||||
)
|
||||
}
|
||||
err is TransportException && err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> {
|
||||
SSHException(
|
||||
DisconnectReason.HOST_KEY_NOT_VERIFIABLE,
|
||||
"WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given [Throwable] is the result of an error caused by the user cancelling the
|
||||
* operation.
|
||||
*/
|
||||
private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean {
|
||||
var cause: Throwable? = throwable
|
||||
while (cause != null) {
|
||||
if (cause is SSHException &&
|
||||
cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER)
|
||||
return true
|
||||
cause = cause.cause
|
||||
}
|
||||
return false
|
||||
/**
|
||||
* Check if a given [Throwable] is the result of an error caused by the user cancelling the
|
||||
* operation.
|
||||
*/
|
||||
private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean {
|
||||
var cause: Throwable? = throwable
|
||||
while (cause != null) {
|
||||
if (cause is SSHException && cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) return true
|
||||
cause = cause.cause
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no
|
||||
* longer found.
|
||||
*/
|
||||
private fun rootCauseException(throwable: Throwable): Throwable {
|
||||
var rootCause = throwable
|
||||
// JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ exceptions.
|
||||
// Also, SSHJ's UserAuthException about exhausting available authentication methods hides
|
||||
// more useful exceptions.
|
||||
while ((rootCause is org.eclipse.jgit.errors.TransportException ||
|
||||
rootCause is org.eclipse.jgit.api.errors.TransportException ||
|
||||
rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException ||
|
||||
(rootCause is UserAuthException &&
|
||||
rootCause.message == "Exhausted available authentication methods"))) {
|
||||
rootCause = rootCause.cause ?: break
|
||||
}
|
||||
return rootCause
|
||||
/**
|
||||
* Get the real root cause of a [Throwable] by traversing until known wrapping exceptions are no
|
||||
* longer found.
|
||||
*/
|
||||
private fun rootCauseException(throwable: Throwable): Throwable {
|
||||
var rootCause = throwable
|
||||
// JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ
|
||||
// exceptions.
|
||||
// Also, SSHJ's UserAuthException about exhausting available authentication methods hides
|
||||
// more useful exceptions.
|
||||
while ((rootCause is org.eclipse.jgit.errors.TransportException ||
|
||||
rootCause is org.eclipse.jgit.api.errors.TransportException ||
|
||||
rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException ||
|
||||
(rootCause is UserAuthException && rootCause.message == "Exhausted available authentication methods"))) {
|
||||
rootCause = rootCause.cause ?: break
|
||||
}
|
||||
return rootCause
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,122 +33,113 @@ import org.eclipse.jgit.lib.RepositoryState
|
|||
|
||||
class GitConfigActivity : BaseGitActivity() {
|
||||
|
||||
private val binding by viewBinding(ActivityGitConfigBinding::inflate)
|
||||
private val binding by viewBinding(ActivityGitConfigBinding::inflate)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
if (GitSettings.authorName.isEmpty())
|
||||
binding.gitUserName.requestFocus()
|
||||
else
|
||||
binding.gitUserName.setText(GitSettings.authorName)
|
||||
binding.gitUserEmail.setText(GitSettings.authorEmail)
|
||||
setupTools()
|
||||
binding.saveButton.setOnClickListener {
|
||||
val email = binding.gitUserEmail.text.toString().trim()
|
||||
val name = binding.gitUserName.text.toString().trim()
|
||||
if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(getString(R.string.invalid_email_dialog_text))
|
||||
.setPositiveButton(getString(R.string.dialog_ok), null)
|
||||
.show()
|
||||
} else {
|
||||
GitSettings.authorEmail = email
|
||||
GitSettings.authorName = name
|
||||
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
|
||||
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
||||
}
|
||||
}
|
||||
if (GitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus()
|
||||
else binding.gitUserName.setText(GitSettings.authorName)
|
||||
binding.gitUserEmail.setText(GitSettings.authorEmail)
|
||||
setupTools()
|
||||
binding.saveButton.setOnClickListener {
|
||||
val email = binding.gitUserEmail.text.toString().trim()
|
||||
val name = binding.gitUserName.text.toString().trim()
|
||||
if (!email.matches(Patterns.EMAIL_ADDRESS.toRegex())) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(getString(R.string.invalid_email_dialog_text))
|
||||
.setPositiveButton(getString(R.string.dialog_ok), null)
|
||||
.show()
|
||||
} else {
|
||||
GitSettings.authorEmail = email
|
||||
GitSettings.authorName = name
|
||||
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
|
||||
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the UI components of the tools section.
|
||||
*/
|
||||
private fun setupTools() {
|
||||
val repo = PasswordRepository.getRepository(null)
|
||||
if (repo != null) {
|
||||
binding.gitHeadStatus.text = headStatusMsg(repo)
|
||||
// enable the abort button only if we're rebasing or merging
|
||||
val needsAbort = repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
|
||||
binding.gitAbortRebase.isEnabled = needsAbort
|
||||
binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
|
||||
}
|
||||
binding.gitLog.setOnClickListener {
|
||||
runCatching {
|
||||
startActivity(Intent(this, GitLogActivity::class.java))
|
||||
}.onFailure { ex ->
|
||||
e(ex) { "Failed to start GitLogActivity" }
|
||||
}
|
||||
}
|
||||
binding.gitAbortRebase.setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED).fold(
|
||||
success = {
|
||||
MaterialAlertDialogBuilder(this@GitConfigActivity).run {
|
||||
setTitle(resources.getString(R.string.git_abort_and_push_title))
|
||||
setMessage(resources.getString(
|
||||
R.string.git_break_out_of_detached_success,
|
||||
GitSettings.branch,
|
||||
"conflicting-${GitSettings.branch}-...",
|
||||
))
|
||||
setOnDismissListener { finish() }
|
||||
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
|
||||
show()
|
||||
}
|
||||
},
|
||||
failure = { err ->
|
||||
promptOnErrorHandler(err) {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
/** Sets up the UI components of the tools section. */
|
||||
private fun setupTools() {
|
||||
val repo = PasswordRepository.getRepository(null)
|
||||
if (repo != null) {
|
||||
binding.gitHeadStatus.text = headStatusMsg(repo)
|
||||
// enable the abort button only if we're rebasing or merging
|
||||
val needsAbort = repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
|
||||
binding.gitAbortRebase.isEnabled = needsAbort
|
||||
binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
|
||||
}
|
||||
binding.gitLog.setOnClickListener {
|
||||
runCatching { startActivity(Intent(this, GitLogActivity::class.java)) }.onFailure { ex ->
|
||||
e(ex) { "Failed to start GitLogActivity" }
|
||||
}
|
||||
}
|
||||
binding.gitAbortRebase.setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED)
|
||||
.fold(
|
||||
success = {
|
||||
MaterialAlertDialogBuilder(this@GitConfigActivity).run {
|
||||
setTitle(resources.getString(R.string.git_abort_and_push_title))
|
||||
setMessage(
|
||||
resources.getString(
|
||||
R.string.git_break_out_of_detached_success,
|
||||
GitSettings.branch,
|
||||
"conflicting-${GitSettings.branch}-...",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.gitResetToRemote.setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
launchGitOperation(GitOp.RESET).fold(
|
||||
success = ::finishOnSuccessHandler,
|
||||
failure = { err ->
|
||||
promptOnErrorHandler(err) {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
setOnDismissListener { finish() }
|
||||
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
|
||||
show()
|
||||
}
|
||||
},
|
||||
failure = { err -> promptOnErrorHandler(err) { finish() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
binding.gitResetToRemote.setOnClickListener {
|
||||
lifecycleScope.launch {
|
||||
launchGitOperation(GitOp.RESET)
|
||||
.fold(
|
||||
success = ::finishOnSuccessHandler,
|
||||
failure = { err -> promptOnErrorHandler(err) { finish() } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user-friendly message about the current state of HEAD.
|
||||
*
|
||||
* The state is recognized to be either pointing to a branch or detached.
|
||||
*/
|
||||
private fun headStatusMsg(repo: Repository): String {
|
||||
return runCatching {
|
||||
val headRef = repo.getRef(Constants.HEAD)
|
||||
if (headRef.isSymbolic) {
|
||||
val branchName = headRef.target.name
|
||||
val shortBranchName = Repository.shortenRefName(branchName)
|
||||
getString(R.string.git_head_on_branch, shortBranchName)
|
||||
} else {
|
||||
val commitHash = headRef.objectId.abbreviate(8).name()
|
||||
getString(R.string.git_head_detached, commitHash)
|
||||
}
|
||||
}.getOrElse { ex ->
|
||||
e(ex) { "Error getting HEAD reference" }
|
||||
getString(R.string.git_head_missing)
|
||||
}
|
||||
/**
|
||||
* Returns a user-friendly message about the current state of HEAD.
|
||||
*
|
||||
* The state is recognized to be either pointing to a branch or detached.
|
||||
*/
|
||||
private fun headStatusMsg(repo: Repository): String {
|
||||
return runCatching {
|
||||
val headRef = repo.getRef(Constants.HEAD)
|
||||
if (headRef.isSymbolic) {
|
||||
val branchName = headRef.target.name
|
||||
val shortBranchName = Repository.shortenRefName(branchName)
|
||||
getString(R.string.git_head_on_branch, shortBranchName)
|
||||
} else {
|
||||
val commitHash = headRef.objectId.abbreviate(8).name()
|
||||
getString(R.string.git_head_detached, commitHash)
|
||||
}
|
||||
}
|
||||
.getOrElse { ex ->
|
||||
e(ex) { "Error getting HEAD reference" }
|
||||
getString(R.string.git_head_missing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,233 +41,226 @@ import kotlinx.coroutines.withContext
|
|||
*/
|
||||
class GitServerConfigActivity : BaseGitActivity() {
|
||||
|
||||
private val binding by viewBinding(ActivityGitCloneBinding::inflate)
|
||||
private val binding by viewBinding(ActivityGitCloneBinding::inflate)
|
||||
|
||||
private lateinit var newAuthMode: AuthMode
|
||||
private lateinit var newAuthMode: AuthMode
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val isClone = intent?.extras?.getBoolean("cloning") ?: false
|
||||
if (isClone) {
|
||||
binding.saveButton.text = getString(R.string.clone_button)
|
||||
}
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
newAuthMode = GitSettings.authMode
|
||||
|
||||
binding.authModeGroup.apply {
|
||||
when (newAuthMode) {
|
||||
AuthMode.SshKey -> check(binding.authModeSshKey.id)
|
||||
AuthMode.Password -> check(binding.authModePassword.id)
|
||||
AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id)
|
||||
AuthMode.None -> check(View.NO_ID)
|
||||
}
|
||||
setOnCheckedChangeListener { _, checkedId ->
|
||||
when (checkedId) {
|
||||
binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
|
||||
binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
|
||||
binding.authModePassword.id -> newAuthMode = AuthMode.Password
|
||||
View.NO_ID -> newAuthMode = AuthMode.None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.serverUrl.setText(GitSettings.url.also {
|
||||
if (it.isNullOrEmpty()) return@also
|
||||
setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
|
||||
})
|
||||
binding.serverBranch.setText(GitSettings.branch)
|
||||
|
||||
binding.serverUrl.doOnTextChanged { text, _, _, _ ->
|
||||
if (text.isNullOrEmpty()) return@doOnTextChanged
|
||||
setAuthModes(text.startsWith("http://") || text.startsWith("https://"))
|
||||
}
|
||||
|
||||
binding.clearHostKeyButton.isVisible = GitSettings.hasSavedHostKey()
|
||||
binding.clearHostKeyButton.setOnClickListener {
|
||||
GitSettings.clearSavedHostKey()
|
||||
Snackbar.make(binding.root, getString(R.string.clear_saved_host_key_success), Snackbar.LENGTH_LONG).show()
|
||||
it.isVisible = false
|
||||
}
|
||||
binding.saveButton.setOnClickListener {
|
||||
val newUrl = binding.serverUrl.text.toString().trim()
|
||||
// If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://`
|
||||
// in the beginning will cause the port to be seen as part of the path. Let users know
|
||||
// about it and offer a quickfix.
|
||||
if (newUrl.contains(PORT_REGEX)) {
|
||||
if (newUrl.startsWith("https://")) {
|
||||
BasicBottomSheet.Builder(this)
|
||||
.setTitleRes(R.string.https_scheme_with_port_title)
|
||||
.setMessageRes(R.string.https_scheme_with_port_message)
|
||||
.setPositiveButtonClickListener {
|
||||
binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/"))
|
||||
}
|
||||
.build()
|
||||
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
||||
return@setOnClickListener
|
||||
} else if (!newUrl.startsWith("ssh://")) {
|
||||
BasicBottomSheet.Builder(this)
|
||||
.setTitleRes(R.string.ssh_scheme_needed_title)
|
||||
.setMessageRes(R.string.ssh_scheme_needed_message)
|
||||
.setPositiveButtonClickListener {
|
||||
@Suppress("SetTextI18n")
|
||||
binding.serverUrl.setText("ssh://$newUrl")
|
||||
}
|
||||
.build()
|
||||
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
||||
return@setOnClickListener
|
||||
}
|
||||
}
|
||||
when (val updateResult = GitSettings.updateConnectionSettingsIfValid(
|
||||
newAuthMode = newAuthMode,
|
||||
newUrl = binding.serverUrl.text.toString().trim(),
|
||||
newBranch = binding.serverBranch.text.toString().trim())) {
|
||||
GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> {
|
||||
Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> {
|
||||
when (updateResult.newProtocol) {
|
||||
Protocol.Https ->
|
||||
BasicBottomSheet.Builder(this)
|
||||
.setTitleRes(R.string.ssh_scheme_needed_title)
|
||||
.setMessageRes(R.string.git_server_config_save_missing_username_https)
|
||||
.setPositiveButtonClickListener {
|
||||
}
|
||||
.build()
|
||||
.show(supportFragmentManager, "HTTPS_MISSING_USERNAME")
|
||||
Protocol.Ssh ->
|
||||
BasicBottomSheet.Builder(this)
|
||||
.setTitleRes(R.string.ssh_scheme_needed_title)
|
||||
.setMessageRes(R.string.git_server_config_save_missing_username_ssh)
|
||||
.setPositiveButtonClickListener {
|
||||
}
|
||||
.build()
|
||||
.show(supportFragmentManager, "SSH_MISSING_USERNAME")
|
||||
}
|
||||
}
|
||||
GitSettings.UpdateConnectionSettingsResult.Valid -> {
|
||||
if (isClone && PasswordRepository.getRepository(null) == null)
|
||||
PasswordRepository.initialize()
|
||||
if (!isClone) {
|
||||
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show()
|
||||
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
||||
} else {
|
||||
cloneRepository()
|
||||
}
|
||||
}
|
||||
is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> {
|
||||
val message = getString(
|
||||
R.string.git_server_config_save_auth_mode_mismatch,
|
||||
updateResult.newProtocol,
|
||||
updateResult.validModes.joinToString(", "),
|
||||
)
|
||||
Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val isClone = intent?.extras?.getBoolean("cloning") ?: false
|
||||
if (isClone) {
|
||||
binding.saveButton.text = getString(R.string.clone_button)
|
||||
}
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
|
||||
newAuthMode = GitSettings.authMode
|
||||
|
||||
binding.authModeGroup.apply {
|
||||
when (newAuthMode) {
|
||||
AuthMode.SshKey -> check(binding.authModeSshKey.id)
|
||||
AuthMode.Password -> check(binding.authModePassword.id)
|
||||
AuthMode.OpenKeychain -> check(binding.authModeOpenKeychain.id)
|
||||
AuthMode.None -> check(View.NO_ID)
|
||||
}
|
||||
setOnCheckedChangeListener { _, checkedId ->
|
||||
when (checkedId) {
|
||||
binding.authModeSshKey.id -> newAuthMode = AuthMode.SshKey
|
||||
binding.authModeOpenKeychain.id -> newAuthMode = AuthMode.OpenKeychain
|
||||
binding.authModePassword.id -> newAuthMode = AuthMode.Password
|
||||
View.NO_ID -> newAuthMode = AuthMode.None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
binding.serverUrl.setText(
|
||||
GitSettings.url.also {
|
||||
if (it.isNullOrEmpty()) return@also
|
||||
setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
|
||||
}
|
||||
)
|
||||
binding.serverBranch.setText(GitSettings.branch)
|
||||
|
||||
binding.serverUrl.doOnTextChanged { text, _, _, _ ->
|
||||
if (text.isNullOrEmpty()) return@doOnTextChanged
|
||||
setAuthModes(text.startsWith("http://") || text.startsWith("https://"))
|
||||
}
|
||||
|
||||
private fun setAuthModes(isHttps: Boolean) = with(binding) {
|
||||
if (isHttps) {
|
||||
authModeSshKey.isVisible = false
|
||||
authModeOpenKeychain.isVisible = false
|
||||
authModePassword.isVisible = true
|
||||
if (authModeGroup.checkedChipId != authModePassword.id)
|
||||
authModeGroup.check(View.NO_ID)
|
||||
} else {
|
||||
authModeSshKey.isVisible = true
|
||||
authModeOpenKeychain.isVisible = true
|
||||
authModePassword.isVisible = true
|
||||
if (authModeGroup.checkedChipId == View.NO_ID)
|
||||
authModeGroup.check(authModeSshKey.id)
|
||||
binding.clearHostKeyButton.isVisible = GitSettings.hasSavedHostKey()
|
||||
binding.clearHostKeyButton.setOnClickListener {
|
||||
GitSettings.clearSavedHostKey()
|
||||
Snackbar.make(binding.root, getString(R.string.clear_saved_host_key_success), Snackbar.LENGTH_LONG).show()
|
||||
it.isVisible = false
|
||||
}
|
||||
binding.saveButton.setOnClickListener {
|
||||
val newUrl = binding.serverUrl.text.toString().trim()
|
||||
// If url is of type john_doe@example.org:12435/path/to/repo, then not adding `ssh://`
|
||||
// in the beginning will cause the port to be seen as part of the path. Let users know
|
||||
// about it and offer a quickfix.
|
||||
if (newUrl.contains(PORT_REGEX)) {
|
||||
if (newUrl.startsWith("https://")) {
|
||||
BasicBottomSheet.Builder(this)
|
||||
.setTitleRes(R.string.https_scheme_with_port_title)
|
||||
.setMessageRes(R.string.https_scheme_with_port_message)
|
||||
.setPositiveButtonClickListener { binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) }
|
||||
.build()
|
||||
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
||||
return@setOnClickListener
|
||||
} else if (!newUrl.startsWith("ssh://")) {
|
||||
BasicBottomSheet.Builder(this)
|
||||
.setTitleRes(R.string.ssh_scheme_needed_title)
|
||||
.setMessageRes(R.string.ssh_scheme_needed_message)
|
||||
.setPositiveButtonClickListener { @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl") }
|
||||
.build()
|
||||
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
||||
return@setOnClickListener
|
||||
}
|
||||
}
|
||||
when (val updateResult =
|
||||
GitSettings.updateConnectionSettingsIfValid(
|
||||
newAuthMode = newAuthMode,
|
||||
newUrl = binding.serverUrl.text.toString().trim(),
|
||||
newBranch = binding.serverBranch.text.toString().trim()
|
||||
)
|
||||
) {
|
||||
GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> {
|
||||
Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> {
|
||||
when (updateResult.newProtocol) {
|
||||
Protocol.Https ->
|
||||
BasicBottomSheet.Builder(this)
|
||||
.setTitleRes(R.string.ssh_scheme_needed_title)
|
||||
.setMessageRes(R.string.git_server_config_save_missing_username_https)
|
||||
.setPositiveButtonClickListener {}
|
||||
.build()
|
||||
.show(supportFragmentManager, "HTTPS_MISSING_USERNAME")
|
||||
Protocol.Ssh ->
|
||||
BasicBottomSheet.Builder(this)
|
||||
.setTitleRes(R.string.ssh_scheme_needed_title)
|
||||
.setMessageRes(R.string.git_server_config_save_missing_username_ssh)
|
||||
.setPositiveButtonClickListener {}
|
||||
.build()
|
||||
.show(supportFragmentManager, "SSH_MISSING_USERNAME")
|
||||
}
|
||||
}
|
||||
GitSettings.UpdateConnectionSettingsResult.Valid -> {
|
||||
if (isClone && PasswordRepository.getRepository(null) == null) PasswordRepository.initialize()
|
||||
if (!isClone) {
|
||||
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
||||
} else {
|
||||
cloneRepository()
|
||||
}
|
||||
}
|
||||
is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> {
|
||||
val message =
|
||||
getString(
|
||||
R.string.git_server_config_save_auth_mode_mismatch,
|
||||
updateResult.newProtocol,
|
||||
updateResult.validModes.joinToString(", "),
|
||||
)
|
||||
Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAuthModes(isHttps: Boolean) =
|
||||
with(binding) {
|
||||
if (isHttps) {
|
||||
authModeSshKey.isVisible = false
|
||||
authModeOpenKeychain.isVisible = false
|
||||
authModePassword.isVisible = true
|
||||
if (authModeGroup.checkedChipId != authModePassword.id) authModeGroup.check(View.NO_ID)
|
||||
} else {
|
||||
authModeSshKey.isVisible = true
|
||||
authModeOpenKeychain.isVisible = true
|
||||
authModePassword.isVisible = true
|
||||
if (authModeGroup.checkedChipId == View.NO_ID) authModeGroup.check(authModeSshKey.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones the repository, the directory exists, deletes it
|
||||
*/
|
||||
private fun cloneRepository() {
|
||||
val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory())
|
||||
val localDirFiles = localDir.listFiles() ?: emptyArray()
|
||||
// Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
|
||||
if (localDir.exists() && localDirFiles.isNotEmpty() &&
|
||||
!(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.dialog_delete_title)
|
||||
.setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.dialog_delete) { dialog, _ ->
|
||||
runCatching {
|
||||
lifecycleScope.launch {
|
||||
val snackbar = snackbar(message = getString(R.string.delete_directory_progress_text), length = Snackbar.LENGTH_INDEFINITE)
|
||||
withContext(Dispatchers.IO) {
|
||||
localDir.deleteRecursively()
|
||||
}
|
||||
snackbar.dismiss()
|
||||
launchGitOperation(GitOp.CLONE).fold(
|
||||
success = {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
},
|
||||
failure = { err ->
|
||||
promptOnErrorHandler(err) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
e.printStackTrace()
|
||||
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
|
||||
}
|
||||
dialog.cancel()
|
||||
}
|
||||
.setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ ->
|
||||
dialog.cancel()
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
runCatching {
|
||||
// Silently delete & replace the lone .git folder if it exists
|
||||
if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
|
||||
localDir.deleteRecursively()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
e(e)
|
||||
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
|
||||
}
|
||||
/** Clones the repository, the directory exists, deletes it */
|
||||
private fun cloneRepository() {
|
||||
val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory())
|
||||
val localDirFiles = localDir.listFiles() ?: emptyArray()
|
||||
// Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
|
||||
if (localDir.exists() && localDirFiles.isNotEmpty() && !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")
|
||||
) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.dialog_delete_title)
|
||||
.setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.dialog_delete) { dialog, _ ->
|
||||
runCatching {
|
||||
lifecycleScope.launch {
|
||||
launchGitOperation(GitOp.CLONE).fold(
|
||||
success = {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
},
|
||||
failure = { promptOnErrorHandler(it) },
|
||||
val snackbar =
|
||||
snackbar(
|
||||
message = getString(R.string.delete_directory_progress_text),
|
||||
length = Snackbar.LENGTH_INDEFINITE
|
||||
)
|
||||
withContext(Dispatchers.IO) { localDir.deleteRecursively() }
|
||||
snackbar.dismiss()
|
||||
launchGitOperation(GitOp.CLONE)
|
||||
.fold(
|
||||
success = {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
},
|
||||
failure = { err -> promptOnErrorHandler(err) { finish() } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
|
||||
|
||||
fun createCloneIntent(context: Context): Intent {
|
||||
return Intent(context, GitServerConfigActivity::class.java).apply {
|
||||
putExtra("cloning", true)
|
||||
}
|
||||
.onFailure { e ->
|
||||
e.printStackTrace()
|
||||
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
|
||||
}
|
||||
dialog.cancel()
|
||||
}
|
||||
.setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() }
|
||||
.show()
|
||||
} else {
|
||||
runCatching {
|
||||
// Silently delete & replace the lone .git folder if it exists
|
||||
if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
|
||||
localDir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
.onFailure { e ->
|
||||
e(e)
|
||||
MaterialAlertDialogBuilder(this).setMessage(e.message).show()
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
launchGitOperation(GitOp.CLONE)
|
||||
.fold(
|
||||
success = {
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
},
|
||||
failure = { promptOnErrorHandler(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
|
||||
|
||||
fun createCloneIntent(context: Context): Intent {
|
||||
return Intent(context, GitServerConfigActivity::class.java).apply { putExtra("cloning", true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,30 +20,30 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
|
|||
*/
|
||||
class GitLogActivity : BaseGitActivity() {
|
||||
|
||||
private val binding by viewBinding(ActivityGitLogBinding::inflate)
|
||||
private val binding by viewBinding(ActivityGitLogBinding::inflate)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
createRecyclerView()
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
createRecyclerView()
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
finish()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRecyclerView() {
|
||||
binding.gitLogRecyclerView.apply {
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
|
||||
adapter = GitLogAdapter()
|
||||
}
|
||||
private fun createRecyclerView() {
|
||||
binding.gitLogRecyclerView.apply {
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
|
||||
adapter = GitLogAdapter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,43 +16,42 @@ import java.text.DateFormat
|
|||
import java.util.Date
|
||||
|
||||
private fun shortHash(hash: String): String {
|
||||
return hash.substring(0 until 8)
|
||||
return hash.substring(0 until 8)
|
||||
}
|
||||
|
||||
private fun stringFrom(date: Date): String {
|
||||
return DateFormat.getDateTimeInstance().format(date)
|
||||
return DateFormat.getDateTimeInstance().format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* @see GitLogActivity
|
||||
*/
|
||||
/** @see GitLogActivity */
|
||||
class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
|
||||
|
||||
private val model = GitLogModel()
|
||||
private val model = GitLogModel()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false)
|
||||
return ViewHolder(binding)
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
val binding = GitLogRowLayoutBinding.inflate(inflater, parent, false)
|
||||
return ViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
|
||||
val commit = model.get(position)
|
||||
if (commit == null) {
|
||||
e { "There is no git commit for view holder at position $position." }
|
||||
return
|
||||
}
|
||||
viewHolder.bind(commit)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
|
||||
val commit = model.get(position)
|
||||
if (commit == null) {
|
||||
e { "There is no git commit for view holder at position $position." }
|
||||
return
|
||||
}
|
||||
viewHolder.bind(commit)
|
||||
}
|
||||
override fun getItemCount() = model.size
|
||||
|
||||
override fun getItemCount() = model.size
|
||||
class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(commit: GitCommit) = with(binding) {
|
||||
gitLogRowMessage.text = commit.shortMessage
|
||||
gitLogRowHash.text = shortHash(commit.hash)
|
||||
gitLogRowTime.text = stringFrom(commit.time)
|
||||
}
|
||||
}
|
||||
fun bind(commit: GitCommit) =
|
||||
with(binding) {
|
||||
gitLogRowMessage.text = commit.shortMessage
|
||||
gitLogRowHash.text = shortHash(commit.hash)
|
||||
gitLogRowTime.text = stringFrom(commit.time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,46 +18,46 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
|||
|
||||
class LaunchActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val prefs = sharedPrefs
|
||||
if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) {
|
||||
BiometricAuthenticator.authenticate(this) {
|
||||
when (it) {
|
||||
is BiometricAuthenticator.Result.Success -> {
|
||||
startTargetActivity(false)
|
||||
}
|
||||
is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
|
||||
prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) }
|
||||
startTargetActivity(false)
|
||||
}
|
||||
is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startTargetActivity(true)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val prefs = sharedPrefs
|
||||
if (prefs.getBoolean(PreferenceKeys.BIOMETRIC_AUTH, false)) {
|
||||
BiometricAuthenticator.authenticate(this) {
|
||||
when (it) {
|
||||
is BiometricAuthenticator.Result.Success -> {
|
||||
startTargetActivity(false)
|
||||
}
|
||||
is BiometricAuthenticator.Result.HardwareUnavailableOrDisabled -> {
|
||||
prefs.edit { remove(PreferenceKeys.BIOMETRIC_AUTH) }
|
||||
startTargetActivity(false)
|
||||
}
|
||||
is BiometricAuthenticator.Result.Failure, BiometricAuthenticator.Result.Cancelled -> {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startTargetActivity(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTargetActivity(noAuth: Boolean) {
|
||||
val intentToStart = if (intent.action == ACTION_DECRYPT_PASS)
|
||||
Intent(this, DecryptActivity::class.java).apply {
|
||||
putExtra("NAME", intent.getStringExtra("NAME"))
|
||||
putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH"))
|
||||
putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH"))
|
||||
putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L))
|
||||
}
|
||||
else
|
||||
Intent(this, PasswordStore::class.java)
|
||||
startActivity(intentToStart)
|
||||
private fun startTargetActivity(noAuth: Boolean) {
|
||||
val intentToStart =
|
||||
if (intent.action == ACTION_DECRYPT_PASS)
|
||||
Intent(this, DecryptActivity::class.java).apply {
|
||||
putExtra("NAME", intent.getStringExtra("NAME"))
|
||||
putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH"))
|
||||
putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH"))
|
||||
putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L))
|
||||
}
|
||||
else Intent(this, PasswordStore::class.java)
|
||||
startActivity(intentToStart)
|
||||
|
||||
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L)
|
||||
}
|
||||
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L)
|
||||
}
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
const val ACTION_DECRYPT_PASS = "DECRYPT_PASS"
|
||||
}
|
||||
const val ACTION_DECRYPT_PASS = "DECRYPT_PASS"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,16 +11,16 @@ import dev.msfjarvis.aps.R
|
|||
|
||||
class OnboardingActivity : AppCompatActivity(R.layout.activity_onboarding) {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
supportActionBar?.hide()
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
supportActionBar?.hide()
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (supportFragmentManager.backStackEntryCount == 0) {
|
||||
finishAffinity()
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
override fun onBackPressed() {
|
||||
if (supportFragmentManager.backStackEntryCount == 0) {
|
||||
finishAffinity()
|
||||
} else {
|
||||
super.onBackPressed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,37 +22,34 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
|||
|
||||
class CloneFragment : Fragment(R.layout.fragment_clone) {
|
||||
|
||||
private val binding by viewBinding(FragmentCloneBinding::bind)
|
||||
private val binding by viewBinding(FragmentCloneBinding::bind)
|
||||
|
||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
||||
|
||||
private val cloneAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
|
||||
finish()
|
||||
}
|
||||
private val cloneAction =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.cloneRemote.setOnClickListener {
|
||||
cloneToHiddenDir()
|
||||
}
|
||||
binding.createLocal.setOnClickListener {
|
||||
parentFragmentManager.performTransactionWithBackStack(RepoLocationFragment.newInstance())
|
||||
}
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.cloneRemote.setOnClickListener { cloneToHiddenDir() }
|
||||
binding.createLocal.setOnClickListener {
|
||||
parentFragmentManager.performTransactionWithBackStack(RepoLocationFragment.newInstance())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a remote Git repository to the app's private directory
|
||||
*/
|
||||
private fun cloneToHiddenDir() {
|
||||
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) }
|
||||
cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
|
||||
}
|
||||
/** Clones a remote Git repository to the app's private directory */
|
||||
private fun cloneToHiddenDir() {
|
||||
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, false) }
|
||||
cloneAction.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
|
||||
}
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
fun newInstance(): CloneFragment = CloneFragment()
|
||||
}
|
||||
fun newInstance(): CloneFragment = CloneFragment()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,37 +30,37 @@ import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
|||
|
||||
class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
|
||||
|
||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
||||
private val binding by viewBinding(FragmentKeySelectionBinding::bind)
|
||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
||||
private val binding by viewBinding(FragmentKeySelectionBinding::bind)
|
||||
|
||||
private val gpgKeySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
|
||||
gpgIdentifierFile.writeText((keyIds + "").joinToString("\n"))
|
||||
}
|
||||
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
|
||||
requireActivity().commitChange(getString(
|
||||
R.string.git_commit_gpg_id,
|
||||
getString(R.string.app_name)
|
||||
))
|
||||
}
|
||||
private val gpgKeySelectAction =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
|
||||
gpgIdentifierFile.writeText((keyIds + "").joinToString("\n"))
|
||||
}
|
||||
} else {
|
||||
throw IllegalStateException("Failed to initialize repository state.")
|
||||
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
|
||||
requireActivity().commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name)))
|
||||
}
|
||||
}
|
||||
finish()
|
||||
} else {
|
||||
throw IllegalStateException("Failed to initialize repository state.")
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.selectKey.setOnClickListener { gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) }
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.selectKey.setOnClickListener {
|
||||
gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
fun newInstance() = KeySelectionFragment()
|
||||
}
|
||||
fun newInstance() = KeySelectionFragment()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,159 +35,155 @@ import java.io.File
|
|||
|
||||
class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
|
||||
|
||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
||||
private val directorySelectIntent by lazy(LazyThreadSafetyMode.NONE) { Intent(requireContext(), DirectorySelectionActivity::class.java) }
|
||||
private val binding by viewBinding(FragmentRepoLocationBinding::bind)
|
||||
private val sortOrder: PasswordSortOrder
|
||||
get() = PasswordSortOrder.getSortOrder(settings)
|
||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
||||
private val directorySelectIntent by lazy(LazyThreadSafetyMode.NONE) {
|
||||
Intent(requireContext(), DirectorySelectionActivity::class.java)
|
||||
}
|
||||
private val binding by viewBinding(FragmentRepoLocationBinding::bind)
|
||||
private val sortOrder: PasswordSortOrder
|
||||
get() = PasswordSortOrder.getSortOrder(settings)
|
||||
|
||||
private val repositoryInitAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
initializeRepositoryInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private val externalDirectorySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
if (checkExternalDirectory()) {
|
||||
finish()
|
||||
} else {
|
||||
createRepository()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val externalDirPermGrantedAction = createPermGrantedAction {
|
||||
externalDirectorySelectAction.launch(directorySelectIntent)
|
||||
}
|
||||
|
||||
private val repositoryUsePermGrantedAction = createPermGrantedAction {
|
||||
private val repositoryInitAction =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
initializeRepositoryInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private val repositoryChangePermGrantedAction = createPermGrantedAction {
|
||||
repositoryInitAction.launch(directorySelectIntent)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.hidden.setOnClickListener {
|
||||
createRepoInHiddenDir()
|
||||
}
|
||||
|
||||
binding.sdcard.setOnClickListener {
|
||||
createRepoFromExternalDir()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes an empty repository in the app's private directory
|
||||
*/
|
||||
private fun createRepoInHiddenDir() {
|
||||
settings.edit {
|
||||
putBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
||||
remove(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
}
|
||||
initializeRepositoryInfo()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes an empty repository in a selected directory if one does not already exist
|
||||
*/
|
||||
private fun createRepoFromExternalDir() {
|
||||
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) }
|
||||
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
if (externalRepo == null) {
|
||||
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
externalDirPermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
// Unlikely we have storage permissions without user ever selecting a directory,
|
||||
// but let's not assume.
|
||||
externalDirectorySelectAction.launch(directorySelectIntent)
|
||||
}
|
||||
private val externalDirectorySelectAction =
|
||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
if (checkExternalDirectory()) {
|
||||
finish()
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireActivity())
|
||||
.setTitle(resources.getString(R.string.directory_selected_title))
|
||||
.setMessage(resources.getString(R.string.directory_selected_message, externalRepo))
|
||||
.setPositiveButton(resources.getString(R.string.use)) { _, _ ->
|
||||
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
repositoryUsePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
initializeRepositoryInfo()
|
||||
}
|
||||
}
|
||||
.setNegativeButton(resources.getString(R.string.change)) { _, _ ->
|
||||
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
repositoryChangePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
repositoryInitAction.launch(directorySelectIntent)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
createRepository()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkExternalDirectory(): Boolean {
|
||||
if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) &&
|
||||
settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null) {
|
||||
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
val dir = externalRepoPath?.let { File(it) }
|
||||
if (dir != null && // The directory could be opened
|
||||
dir.exists() && // The directory exists
|
||||
dir.isDirectory && // The directory, is really a directory
|
||||
dir.listFilesRecursively().isNotEmpty() && // The directory contains files
|
||||
// The directory contains a non-zero number of password files
|
||||
PasswordRepository.getPasswords(dir, PasswordRepository.getRepositoryDirectory(), sortOrder).isNotEmpty()
|
||||
) {
|
||||
PasswordRepository.closeRepository()
|
||||
return true
|
||||
}
|
||||
private val externalDirPermGrantedAction = createPermGrantedAction {
|
||||
externalDirectorySelectAction.launch(directorySelectIntent)
|
||||
}
|
||||
|
||||
private val repositoryUsePermGrantedAction = createPermGrantedAction { initializeRepositoryInfo() }
|
||||
|
||||
private val repositoryChangePermGrantedAction = createPermGrantedAction {
|
||||
repositoryInitAction.launch(directorySelectIntent)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.hidden.setOnClickListener { createRepoInHiddenDir() }
|
||||
|
||||
binding.sdcard.setOnClickListener { createRepoFromExternalDir() }
|
||||
}
|
||||
|
||||
/** Initializes an empty repository in the app's private directory */
|
||||
private fun createRepoInHiddenDir() {
|
||||
settings.edit {
|
||||
putBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
||||
remove(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
}
|
||||
initializeRepositoryInfo()
|
||||
}
|
||||
|
||||
/** Initializes an empty repository in a selected directory if one does not already exist */
|
||||
private fun createRepoFromExternalDir() {
|
||||
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) }
|
||||
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
if (externalRepo == null) {
|
||||
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
externalDirPermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
// Unlikely we have storage permissions without user ever selecting a directory,
|
||||
// but let's not assume.
|
||||
externalDirectorySelectAction.launch(directorySelectIntent)
|
||||
}
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireActivity())
|
||||
.setTitle(resources.getString(R.string.directory_selected_title))
|
||||
.setMessage(resources.getString(R.string.directory_selected_message, externalRepo))
|
||||
.setPositiveButton(resources.getString(R.string.use)) { _, _ ->
|
||||
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
repositoryUsePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
initializeRepositoryInfo()
|
||||
}
|
||||
}
|
||||
return false
|
||||
.setNegativeButton(resources.getString(R.string.change)) { _, _ ->
|
||||
if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
repositoryChangePermGrantedAction.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
} else {
|
||||
repositoryInitAction.launch(directorySelectIntent)
|
||||
}
|
||||
}
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkExternalDirectory(): Boolean {
|
||||
if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) &&
|
||||
settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null
|
||||
) {
|
||||
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
val dir = externalRepoPath?.let { File(it) }
|
||||
if (dir != null && // The directory could be opened
|
||||
dir.exists() && // The directory exists
|
||||
dir.isDirectory && // The directory, is really a directory
|
||||
dir.listFilesRecursively().isNotEmpty() && // The directory contains files
|
||||
// The directory contains a non-zero number of password files
|
||||
PasswordRepository.getPasswords(dir, PasswordRepository.getRepositoryDirectory(), sortOrder).isNotEmpty()
|
||||
) {
|
||||
PasswordRepository.closeRepository()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun createRepository() {
|
||||
val localDir = PasswordRepository.getRepositoryDirectory()
|
||||
runCatching {
|
||||
check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" }
|
||||
PasswordRepository.createRepository(localDir)
|
||||
if (!PasswordRepository.isInitialized) {
|
||||
PasswordRepository.initialize()
|
||||
}
|
||||
parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance())
|
||||
}
|
||||
.onFailure { e ->
|
||||
e(e)
|
||||
if (!localDir.delete()) {
|
||||
d { "Failed to delete local repository: $localDir" }
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeRepositoryInfo() {
|
||||
val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
||||
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
if (externalRepo && !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
return
|
||||
}
|
||||
if (externalRepo && externalRepoPath != null) {
|
||||
if (checkExternalDirectory()) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
createRepository()
|
||||
}
|
||||
|
||||
private fun createPermGrantedAction(block: () -> Unit) =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
block.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRepository() {
|
||||
val localDir = PasswordRepository.getRepositoryDirectory()
|
||||
runCatching {
|
||||
check(localDir.exists() || localDir.mkdir()) { "Failed to create directory!" }
|
||||
PasswordRepository.createRepository(localDir)
|
||||
if (!PasswordRepository.isInitialized) {
|
||||
PasswordRepository.initialize()
|
||||
}
|
||||
parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance())
|
||||
}.onFailure { e ->
|
||||
e(e)
|
||||
if (!localDir.delete()) {
|
||||
d { "Failed to delete local repository: $localDir" }
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
companion object {
|
||||
|
||||
private fun initializeRepositoryInfo() {
|
||||
val externalRepo = settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
||||
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
if (externalRepo && !isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
|
||||
return
|
||||
}
|
||||
if (externalRepo && externalRepoPath != null) {
|
||||
if (checkExternalDirectory()) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
createRepository()
|
||||
}
|
||||
|
||||
private fun createPermGrantedAction(block: () -> Unit) =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) {
|
||||
block.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newInstance(): RepoLocationFragment = RepoLocationFragment()
|
||||
}
|
||||
fun newInstance(): RepoLocationFragment = RepoLocationFragment()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,11 +20,13 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
|
|||
@Suppress("unused")
|
||||
class WelcomeFragment : Fragment(R.layout.fragment_welcome) {
|
||||
|
||||
private val binding by viewBinding(FragmentWelcomeBinding::bind)
|
||||
private val binding by viewBinding(FragmentWelcomeBinding::bind)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.letsGo.setOnClickListener { parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) }
|
||||
binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), SettingsActivity::class.java)) }
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
binding.letsGo.setOnClickListener {
|
||||
parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance())
|
||||
}
|
||||
binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), SettingsActivity::class.java)) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,296 +49,278 @@ import me.zhanghai.android.fastscroll.FastScrollerBuilder
|
|||
|
||||
class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||
|
||||
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
|
||||
private lateinit var listener: OnFragmentInteractionListener
|
||||
private lateinit var settings: SharedPreferences
|
||||
private lateinit var recyclerAdapter: PasswordItemRecyclerAdapter
|
||||
private lateinit var listener: OnFragmentInteractionListener
|
||||
private lateinit var settings: SharedPreferences
|
||||
|
||||
private var recyclerViewStateToRestore: Parcelable? = null
|
||||
private var actionMode: ActionMode? = null
|
||||
private var scrollTarget: File? = null
|
||||
private var recyclerViewStateToRestore: Parcelable? = null
|
||||
private var actionMode: ActionMode? = null
|
||||
private var scrollTarget: File? = null
|
||||
|
||||
private val model: SearchableRepositoryViewModel by activityViewModels()
|
||||
private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
|
||||
private val swipeResult = registerForActivityResult(StartActivityForResult()) {
|
||||
binding.swipeRefresher.isRefreshing = false
|
||||
private val model: SearchableRepositoryViewModel by activityViewModels()
|
||||
private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
|
||||
private val swipeResult =
|
||||
registerForActivityResult(StartActivityForResult()) {
|
||||
binding.swipeRefresher.isRefreshing = false
|
||||
requireStore().refreshPasswordList()
|
||||
}
|
||||
|
||||
val currentDir: File
|
||||
get() = model.currentDir.value!!
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
settings = requireContext().sharedPrefs
|
||||
initializePasswordList()
|
||||
binding.fab.setOnClickListener { ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") }
|
||||
childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
||||
when (bundle.getString(ACTION_KEY)) {
|
||||
ACTION_FOLDER -> requireStore().createFolder()
|
||||
ACTION_PASSWORD -> requireStore().createPassword()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializePasswordList() {
|
||||
val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
|
||||
val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
|
||||
binding.swipeRefresher.setOnRefreshListener {
|
||||
if (!hasGitDir) {
|
||||
requireStore().refreshPasswordList()
|
||||
binding.swipeRefresher.isRefreshing = false
|
||||
} else if (!PasswordRepository.isGitRepo()) {
|
||||
BasicBottomSheet.Builder(requireContext())
|
||||
.setMessageRes(R.string.clone_git_repo)
|
||||
.setPositiveButtonClickListener(getString(R.string.clone_button)) {
|
||||
swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
|
||||
}
|
||||
.build()
|
||||
.show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO")
|
||||
binding.swipeRefresher.isRefreshing = false
|
||||
} else {
|
||||
// When authentication is set to AuthMode.None then the only git operation we can
|
||||
// run is a pull, so automatically fallback to that.
|
||||
val operationId =
|
||||
when (GitSettings.authMode) {
|
||||
AuthMode.None -> BaseGitActivity.GitOp.PULL
|
||||
else -> BaseGitActivity.GitOp.SYNC
|
||||
}
|
||||
requireStore().apply {
|
||||
lifecycleScope.launch {
|
||||
launchGitOperation(operationId)
|
||||
.fold(
|
||||
success = {
|
||||
binding.swipeRefresher.isRefreshing = false
|
||||
refreshPasswordList()
|
||||
},
|
||||
failure = { err -> promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val currentDir: File
|
||||
get() = model.currentDir.value!!
|
||||
recyclerAdapter =
|
||||
PasswordItemRecyclerAdapter()
|
||||
.onItemClicked { _, item -> listener.onFragmentInteraction(item) }
|
||||
.onSelectionChanged { selection ->
|
||||
// In order to not interfere with drag selection, we disable the
|
||||
// SwipeRefreshLayout
|
||||
// once an item is selected.
|
||||
binding.swipeRefresher.isEnabled = selection.isEmpty
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
settings = requireContext().sharedPrefs
|
||||
initializePasswordList()
|
||||
binding.fab.setOnClickListener {
|
||||
ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET")
|
||||
}
|
||||
childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
||||
when (bundle.getString(ACTION_KEY)) {
|
||||
ACTION_FOLDER -> requireStore().createFolder()
|
||||
ACTION_PASSWORD -> requireStore().createPassword()
|
||||
}
|
||||
if (actionMode == null)
|
||||
actionMode = requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged
|
||||
|
||||
if (!selection.isEmpty) {
|
||||
actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size())
|
||||
actionMode!!.invalidate()
|
||||
} else {
|
||||
actionMode!!.finish()
|
||||
}
|
||||
}
|
||||
val recyclerView = binding.passRecycler
|
||||
recyclerView.apply {
|
||||
addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
itemAnimator = OnOffItemAnimator()
|
||||
adapter = recyclerAdapter
|
||||
}
|
||||
|
||||
private fun initializePasswordList() {
|
||||
val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
|
||||
val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
|
||||
binding.swipeRefresher.setOnRefreshListener {
|
||||
if (!hasGitDir) {
|
||||
requireStore().refreshPasswordList()
|
||||
binding.swipeRefresher.isRefreshing = false
|
||||
} else if (!PasswordRepository.isGitRepo()) {
|
||||
BasicBottomSheet.Builder(requireContext())
|
||||
.setMessageRes(R.string.clone_git_repo)
|
||||
.setPositiveButtonClickListener(getString(R.string.clone_button)) {
|
||||
swipeResult.launch(GitServerConfigActivity.createCloneIntent(requireContext()))
|
||||
}
|
||||
.build()
|
||||
.show(requireActivity().supportFragmentManager, "NOT_A_GIT_REPO")
|
||||
binding.swipeRefresher.isRefreshing = false
|
||||
} else {
|
||||
// When authentication is set to AuthMode.None then the only git operation we can
|
||||
// run is a pull, so automatically fallback to that.
|
||||
val operationId = when (GitSettings.authMode) {
|
||||
AuthMode.None -> BaseGitActivity.GitOp.PULL
|
||||
else -> BaseGitActivity.GitOp.SYNC
|
||||
}
|
||||
requireStore().apply {
|
||||
lifecycleScope.launch {
|
||||
launchGitOperation(operationId).fold(
|
||||
success = {
|
||||
binding.swipeRefresher.isRefreshing = false
|
||||
refreshPasswordList()
|
||||
},
|
||||
failure = { err ->
|
||||
promptOnErrorHandler(err) {
|
||||
binding.swipeRefresher.isRefreshing = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recyclerAdapter = PasswordItemRecyclerAdapter()
|
||||
.onItemClicked { _, item ->
|
||||
listener.onFragmentInteraction(item)
|
||||
}
|
||||
.onSelectionChanged { selection ->
|
||||
// In order to not interfere with drag selection, we disable the SwipeRefreshLayout
|
||||
// once an item is selected.
|
||||
binding.swipeRefresher.isEnabled = selection.isEmpty
|
||||
|
||||
if (actionMode == null)
|
||||
actionMode = requireStore().startSupportActionMode(actionModeCallback)
|
||||
?: return@onSelectionChanged
|
||||
|
||||
if (!selection.isEmpty) {
|
||||
actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size())
|
||||
actionMode!!.invalidate()
|
||||
} else {
|
||||
actionMode!!.finish()
|
||||
}
|
||||
}
|
||||
val recyclerView = binding.passRecycler
|
||||
recyclerView.apply {
|
||||
addItemDecoration(DividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL))
|
||||
layoutManager = LinearLayoutManager(requireContext())
|
||||
itemAnimator = OnOffItemAnimator()
|
||||
adapter = recyclerAdapter
|
||||
}
|
||||
|
||||
FastScrollerBuilder(recyclerView).build()
|
||||
recyclerAdapter.makeSelectable(recyclerView)
|
||||
registerForContextMenu(recyclerView)
|
||||
|
||||
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
|
||||
model.navigateTo(File(path), pushPreviousLocation = false)
|
||||
model.searchResult.observe(viewLifecycleOwner) { result ->
|
||||
// Only run animations when the new list is filtered, i.e., the user submitted a search,
|
||||
// and not on folder navigations since the latter leads to too many removal animations.
|
||||
(recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
|
||||
recyclerAdapter.submitList(result.passwordItems) {
|
||||
when {
|
||||
result.isFiltered -> {
|
||||
// When the result is filtered, we always scroll to the top since that is where
|
||||
// the best fuzzy match appears.
|
||||
recyclerView.scrollToPosition(0)
|
||||
}
|
||||
scrollTarget != null -> {
|
||||
scrollTarget?.let {
|
||||
recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it))
|
||||
}
|
||||
scrollTarget = null
|
||||
}
|
||||
else -> {
|
||||
// When the result is not filtered and there is a saved scroll position for it,
|
||||
// we try to restore it.
|
||||
recyclerViewStateToRestore?.let {
|
||||
recyclerView.layoutManager!!.onRestoreInstanceState(it)
|
||||
}
|
||||
recyclerViewStateToRestore = null
|
||||
}
|
||||
}
|
||||
}
|
||||
FastScrollerBuilder(recyclerView).build()
|
||||
recyclerAdapter.makeSelectable(recyclerView)
|
||||
registerForContextMenu(recyclerView)
|
||||
|
||||
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
|
||||
model.navigateTo(File(path), pushPreviousLocation = false)
|
||||
model.searchResult.observe(viewLifecycleOwner) { result ->
|
||||
// Only run animations when the new list is filtered, i.e., the user submitted a search,
|
||||
// and not on folder navigations since the latter leads to too many removal animations.
|
||||
(recyclerView.itemAnimator as OnOffItemAnimator).isEnabled = result.isFiltered
|
||||
recyclerAdapter.submitList(result.passwordItems) {
|
||||
when {
|
||||
result.isFiltered -> {
|
||||
// When the result is filtered, we always scroll to the top since that is
|
||||
// where
|
||||
// the best fuzzy match appears.
|
||||
recyclerView.scrollToPosition(0)
|
||||
}
|
||||
scrollTarget != null -> {
|
||||
scrollTarget?.let { recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) }
|
||||
scrollTarget = null
|
||||
}
|
||||
else -> {
|
||||
// When the result is not filtered and there is a saved scroll position for
|
||||
// it,
|
||||
// we try to restore it.
|
||||
recyclerViewStateToRestore?.let { recyclerView.layoutManager!!.onRestoreInstanceState(it) }
|
||||
recyclerViewStateToRestore = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val actionModeCallback = object : ActionMode.Callback {
|
||||
// Called when the action mode is created; startActionMode() was called
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
// Inflate a menu resource providing context menu items
|
||||
mode.menuInflater.inflate(R.menu.context_pass, menu)
|
||||
// hide the fab
|
||||
animateFab(false)
|
||||
return true
|
||||
}
|
||||
|
||||
// Called each time the action mode is shown. Always called after onCreateActionMode, but
|
||||
// may be called multiple times if the mode is invalidated.
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
menu.findItem(R.id.menu_edit_password).isVisible =
|
||||
recyclerAdapter.getSelectedItems()
|
||||
.all { it.type == PasswordItem.TYPE_CATEGORY }
|
||||
return true
|
||||
}
|
||||
|
||||
// Called when the user selects a contextual menu item
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.menu_delete_password -> {
|
||||
requireStore().deletePasswords(recyclerAdapter.getSelectedItems())
|
||||
// Action picked, so close the CAB
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
R.id.menu_move_password -> {
|
||||
requireStore().movePasswords(recyclerAdapter.getSelectedItems())
|
||||
false
|
||||
}
|
||||
R.id.menu_edit_password -> {
|
||||
requireStore().renameCategory(recyclerAdapter.getSelectedItems())
|
||||
mode.finish()
|
||||
false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
// Called when the user exits the action mode
|
||||
override fun onDestroyActionMode(mode: ActionMode) {
|
||||
recyclerAdapter.requireSelectionTracker().clearSelection()
|
||||
actionMode = null
|
||||
// show the fab
|
||||
animateFab(true)
|
||||
}
|
||||
|
||||
private fun animateFab(show: Boolean) = with(binding.fab) {
|
||||
val animation = AnimationUtils.loadAnimation(
|
||||
context, if (show) R.anim.scale_up else R.anim.scale_down
|
||||
)
|
||||
animation.setAnimationListener(object : Animation.AnimationListener {
|
||||
override fun onAnimationRepeat(animation: Animation?) {
|
||||
}
|
||||
|
||||
override fun onAnimationEnd(animation: Animation?) {
|
||||
if (!show) visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun onAnimationStart(animation: Animation?) {
|
||||
if (show) visibility = View.VISIBLE
|
||||
}
|
||||
})
|
||||
animate().rotationBy(if (show) -90f else 90f)
|
||||
.setStartDelay(if (show) 100 else 0)
|
||||
.setDuration(100)
|
||||
.start()
|
||||
startAnimation(animation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
runCatching {
|
||||
listener = object : OnFragmentInteractionListener {
|
||||
override fun onFragmentInteraction(item: PasswordItem) {
|
||||
if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) {
|
||||
//save the time when password was used
|
||||
val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
||||
preferences.edit {
|
||||
putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString())
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
||||
navigateTo(item.file)
|
||||
} else {
|
||||
if (requireArguments().getBoolean("matchWith", false)) {
|
||||
requireStore().matchPasswordWithApp(item)
|
||||
} else {
|
||||
requireStore().decryptPassword(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
throw ClassCastException("$context must implement OnFragmentInteractionListener")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireStore() = requireActivity() as PasswordStore
|
||||
|
||||
/**
|
||||
* Returns true if the back press was handled by the [Fragment].
|
||||
*/
|
||||
fun onBackPressedInActivity(): Boolean {
|
||||
if (!model.canNavigateBack)
|
||||
return false
|
||||
// The RecyclerView state is restored when the asynchronous update operation on the
|
||||
// adapter is completed.
|
||||
recyclerViewStateToRestore = model.navigateBack()
|
||||
if (!model.canNavigateBack)
|
||||
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||
private val actionModeCallback =
|
||||
object : ActionMode.Callback {
|
||||
// Called when the action mode is created; startActionMode() was called
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
// Inflate a menu resource providing context menu items
|
||||
mode.menuInflater.inflate(R.menu.context_pass, menu)
|
||||
// hide the fab
|
||||
animateFab(false)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissActionMode() {
|
||||
actionMode?.finish()
|
||||
}
|
||||
// Called each time the action mode is shown. Always called after onCreateActionMode,
|
||||
// but
|
||||
// may be called multiple times if the mode is invalidated.
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
menu.findItem(R.id.menu_edit_password).isVisible =
|
||||
recyclerAdapter.getSelectedItems().all { it.type == PasswordItem.TYPE_CATEGORY }
|
||||
return true
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Called when the user selects a contextual menu item
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.menu_delete_password -> {
|
||||
requireStore().deletePasswords(recyclerAdapter.getSelectedItems())
|
||||
// Action picked, so close the CAB
|
||||
mode.finish()
|
||||
true
|
||||
}
|
||||
R.id.menu_move_password -> {
|
||||
requireStore().movePasswords(recyclerAdapter.getSelectedItems())
|
||||
false
|
||||
}
|
||||
R.id.menu_edit_password -> {
|
||||
requireStore().renameCategory(recyclerAdapter.getSelectedItems())
|
||||
mode.finish()
|
||||
false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
const val ITEM_CREATION_REQUEST_KEY = "creation_key"
|
||||
const val ACTION_KEY = "action"
|
||||
const val ACTION_FOLDER = "folder"
|
||||
const val ACTION_PASSWORD = "password"
|
||||
// Called when the user exits the action mode
|
||||
override fun onDestroyActionMode(mode: ActionMode) {
|
||||
recyclerAdapter.requireSelectionTracker().clearSelection()
|
||||
actionMode = null
|
||||
// show the fab
|
||||
animateFab(true)
|
||||
}
|
||||
|
||||
fun newInstance(args: Bundle): PasswordFragment {
|
||||
val fragment = PasswordFragment()
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
private fun animateFab(show: Boolean) =
|
||||
with(binding.fab) {
|
||||
val animation = AnimationUtils.loadAnimation(context, if (show) R.anim.scale_up else R.anim.scale_down)
|
||||
animation.setAnimationListener(
|
||||
object : Animation.AnimationListener {
|
||||
override fun onAnimationRepeat(animation: Animation?) {}
|
||||
|
||||
override fun onAnimationEnd(animation: Animation?) {
|
||||
if (!show) visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun onAnimationStart(animation: Animation?) {
|
||||
if (show) visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
)
|
||||
animate().rotationBy(if (show) -90f else 90f).setStartDelay(if (show) 100 else 0).setDuration(100).start()
|
||||
startAnimation(animation)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
runCatching {
|
||||
listener =
|
||||
object : OnFragmentInteractionListener {
|
||||
override fun onFragmentInteraction(item: PasswordItem) {
|
||||
if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) {
|
||||
// save the time when password was used
|
||||
val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
||||
preferences.edit { putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) }
|
||||
}
|
||||
|
||||
fun navigateTo(file: File) {
|
||||
requireStore().clearSearch()
|
||||
model.navigateTo(
|
||||
file,
|
||||
recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState()
|
||||
)
|
||||
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
||||
navigateTo(item.file)
|
||||
} else {
|
||||
if (requireArguments().getBoolean("matchWith", false)) {
|
||||
requireStore().matchPasswordWithApp(item)
|
||||
} else {
|
||||
requireStore().decryptPassword(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { throw ClassCastException("$context must implement OnFragmentInteractionListener") }
|
||||
}
|
||||
|
||||
fun scrollToOnNextRefresh(file: File) {
|
||||
scrollTarget = file
|
||||
private fun requireStore() = requireActivity() as PasswordStore
|
||||
|
||||
/** Returns true if the back press was handled by the [Fragment]. */
|
||||
fun onBackPressedInActivity(): Boolean {
|
||||
if (!model.canNavigateBack) return false
|
||||
// The RecyclerView state is restored when the asynchronous update operation on the
|
||||
// adapter is completed.
|
||||
recyclerViewStateToRestore = model.navigateBack()
|
||||
if (!model.canNavigateBack) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||
return true
|
||||
}
|
||||
|
||||
fun dismissActionMode() {
|
||||
actionMode?.finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ITEM_CREATION_REQUEST_KEY = "creation_key"
|
||||
const val ACTION_KEY = "action"
|
||||
const val ACTION_FOLDER = "folder"
|
||||
const val ACTION_PASSWORD = "password"
|
||||
|
||||
fun newInstance(args: Bundle): PasswordFragment {
|
||||
val fragment = PasswordFragment()
|
||||
fragment.arguments = args
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
||||
interface OnFragmentInteractionListener {
|
||||
fun navigateTo(file: File) {
|
||||
requireStore().clearSearch()
|
||||
model.navigateTo(file, recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState())
|
||||
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
|
||||
fun onFragmentInteraction(item: PasswordItem)
|
||||
}
|
||||
fun scrollToOnNextRefresh(file: File) {
|
||||
scrollTarget = file
|
||||
}
|
||||
|
||||
interface OnFragmentInteractionListener {
|
||||
|
||||
fun onFragmentInteraction(item: PasswordItem)
|
||||
}
|
||||
}
|
||||
|
|
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() {
|
||||
|
||||
private val binding by viewBinding(ActivityProxySelectorBinding::inflate)
|
||||
private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) { applicationContext.getEncryptedProxyPrefs() }
|
||||
private val binding by viewBinding(ActivityProxySelectorBinding::inflate)
|
||||
private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) { applicationContext.getEncryptedProxyPrefs() }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
with(binding) {
|
||||
proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST))
|
||||
proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME))
|
||||
proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let {
|
||||
proxyPort.setText("$it")
|
||||
}
|
||||
proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD))
|
||||
save.setOnClickListener { saveSettings() }
|
||||
proxyHost.doOnTextChanged { text, _, _, _ ->
|
||||
if (text != null) {
|
||||
proxyHost.error = if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) {
|
||||
null
|
||||
} else {
|
||||
getString(R.string.invalid_proxy_url)
|
||||
}
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
with(binding) {
|
||||
proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST))
|
||||
proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME))
|
||||
proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let { proxyPort.setText("$it") }
|
||||
proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD))
|
||||
save.setOnClickListener { saveSettings() }
|
||||
proxyHost.doOnTextChanged { text, _, _, _ ->
|
||||
if (text != null) {
|
||||
proxyHost.error =
|
||||
if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) {
|
||||
null
|
||||
} else {
|
||||
getString(R.string.invalid_proxy_url)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveSettings() {
|
||||
proxyPrefs.edit {
|
||||
binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let {
|
||||
GitSettings.proxyHost = it
|
||||
}
|
||||
binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let {
|
||||
GitSettings.proxyUsername = it
|
||||
}
|
||||
binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let {
|
||||
GitSettings.proxyPort = it.toInt()
|
||||
}
|
||||
binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let {
|
||||
GitSettings.proxyPassword = it
|
||||
}
|
||||
}
|
||||
ProxyUtils.setDefaultProxy()
|
||||
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
||||
private fun saveSettings() {
|
||||
proxyPrefs.edit {
|
||||
binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyHost = it }
|
||||
binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyUsername = it }
|
||||
binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let { GitSettings.proxyPort = it.toInt() }
|
||||
binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyPassword = it }
|
||||
}
|
||||
ProxyUtils.setDefaultProxy()
|
||||
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,94 +33,94 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
|||
|
||||
class AutofillSettings(private val activity: FragmentActivity) : SettingsProvider {
|
||||
|
||||
private val isAutofillServiceEnabled: Boolean
|
||||
get() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
|
||||
return activity.autofillManager?.hasEnabledAutofillServices() == true
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun showAutofillDialog(pref: SwitchPreference) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
pref.checked = isAutofillServiceEnabled
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
MaterialAlertDialogBuilder(activity).run {
|
||||
setTitle(R.string.pref_autofill_enable_title)
|
||||
@SuppressLint("InflateParams")
|
||||
val layout =
|
||||
activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
|
||||
val supportedBrowsersTextView =
|
||||
layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
|
||||
supportedBrowsersTextView.text =
|
||||
getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(
|
||||
separator = "\n"
|
||||
) {
|
||||
val appLabel = it.first
|
||||
val supportDescription = when (it.second) {
|
||||
BrowserAutofillSupportLevel.None -> activity.getString(R.string.oreo_autofill_no_support)
|
||||
BrowserAutofillSupportLevel.FlakyFill -> activity.getString(R.string.oreo_autofill_flaky_fill_support)
|
||||
BrowserAutofillSupportLevel.PasswordFill -> activity.getString(R.string.oreo_autofill_password_fill_support)
|
||||
BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility -> activity.getString(R.string.oreo_autofill_password_fill_and_conditional_save_support)
|
||||
BrowserAutofillSupportLevel.GeneralFill -> activity.getString(R.string.oreo_autofill_general_fill_support)
|
||||
BrowserAutofillSupportLevel.GeneralFillAndSave -> activity.getString(R.string.oreo_autofill_general_fill_and_save_support)
|
||||
}
|
||||
"$appLabel: $supportDescription"
|
||||
}
|
||||
setView(layout)
|
||||
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||
val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
|
||||
data = Uri.parse("package:${BuildConfig.APPLICATION_ID}")
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
setNegativeButton(R.string.dialog_cancel, null)
|
||||
setOnDismissListener { pref.checked = isAutofillServiceEnabled }
|
||||
activity.lifecycle.addObserver(observer)
|
||||
show()
|
||||
}
|
||||
private val isAutofillServiceEnabled: Boolean
|
||||
get() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
|
||||
return activity.autofillManager?.hasEnabledAutofillServices() == true
|
||||
}
|
||||
|
||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||
builder.apply {
|
||||
switch(PreferenceKeys.AUTOFILL_ENABLE) {
|
||||
titleRes = R.string.pref_autofill_enable_title
|
||||
visible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
defaultValue = isAutofillServiceEnabled
|
||||
onClick {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return@onClick true
|
||||
if (isAutofillServiceEnabled) {
|
||||
activity.autofillManager?.disableAutofillServices()
|
||||
} else {
|
||||
showAutofillDialog(this)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
val values = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_values)
|
||||
val titles = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_entries)
|
||||
val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) }
|
||||
singleChoice(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE, items) {
|
||||
initialSelection = DirectoryStructure.DEFAULT.value
|
||||
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
||||
titleRes = R.string.oreo_autofill_preference_directory_structure
|
||||
}
|
||||
editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) {
|
||||
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
||||
titleRes = R.string.preference_default_username_title
|
||||
summaryProvider = { activity.getString(R.string.preference_default_username_summary) }
|
||||
}
|
||||
editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) {
|
||||
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
||||
titleRes = R.string.preference_custom_public_suffixes_title
|
||||
summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) }
|
||||
textInputHintRes = R.string.preference_custom_public_suffixes_hint
|
||||
}
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun showAutofillDialog(pref: SwitchPreference) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
pref.checked = isAutofillServiceEnabled
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
MaterialAlertDialogBuilder(activity).run {
|
||||
setTitle(R.string.pref_autofill_enable_title)
|
||||
@SuppressLint("InflateParams")
|
||||
val layout = activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
|
||||
val supportedBrowsersTextView = layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
|
||||
supportedBrowsersTextView.text =
|
||||
getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(separator = "\n") {
|
||||
val appLabel = it.first
|
||||
val supportDescription =
|
||||
when (it.second) {
|
||||
BrowserAutofillSupportLevel.None -> activity.getString(R.string.oreo_autofill_no_support)
|
||||
BrowserAutofillSupportLevel.FlakyFill -> activity.getString(R.string.oreo_autofill_flaky_fill_support)
|
||||
BrowserAutofillSupportLevel.PasswordFill ->
|
||||
activity.getString(R.string.oreo_autofill_password_fill_support)
|
||||
BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility ->
|
||||
activity.getString(R.string.oreo_autofill_password_fill_and_conditional_save_support)
|
||||
BrowserAutofillSupportLevel.GeneralFill -> activity.getString(R.string.oreo_autofill_general_fill_support)
|
||||
BrowserAutofillSupportLevel.GeneralFillAndSave ->
|
||||
activity.getString(R.string.oreo_autofill_general_fill_and_save_support)
|
||||
}
|
||||
"$appLabel: $supportDescription"
|
||||
}
|
||||
setView(layout)
|
||||
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||
val intent =
|
||||
Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
|
||||
data = Uri.parse("package:${BuildConfig.APPLICATION_ID}")
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
setNegativeButton(R.string.dialog_cancel, null)
|
||||
setOnDismissListener { pref.checked = isAutofillServiceEnabled }
|
||||
activity.lifecycle.addObserver(observer)
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||
builder.apply {
|
||||
switch(PreferenceKeys.AUTOFILL_ENABLE) {
|
||||
titleRes = R.string.pref_autofill_enable_title
|
||||
visible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
defaultValue = isAutofillServiceEnabled
|
||||
onClick {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return@onClick true
|
||||
if (isAutofillServiceEnabled) {
|
||||
activity.autofillManager?.disableAutofillServices()
|
||||
} else {
|
||||
showAutofillDialog(this)
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
val values = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_values)
|
||||
val titles = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_entries)
|
||||
val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) }
|
||||
singleChoice(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE, items) {
|
||||
initialSelection = DirectoryStructure.DEFAULT.value
|
||||
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
||||
titleRes = R.string.oreo_autofill_preference_directory_structure
|
||||
}
|
||||
editText(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME) {
|
||||
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
||||
titleRes = R.string.preference_default_username_title
|
||||
summaryProvider = { activity.getString(R.string.preference_default_username_summary) }
|
||||
}
|
||||
editText(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES) {
|
||||
dependency = PreferenceKeys.AUTOFILL_ENABLE
|
||||
titleRes = R.string.preference_custom_public_suffixes_title
|
||||
summaryProvider = { activity.getString(R.string.preference_custom_public_suffixes_summary) }
|
||||
textInputHintRes = R.string.preference_custom_public_suffixes_hint
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,37 +20,38 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
|||
|
||||
class DirectorySelectionActivity : AppCompatActivity() {
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private val directorySelectAction = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
|
||||
if (uri == null) return@registerForActivityResult
|
||||
@Suppress("DEPRECATION")
|
||||
private val directorySelectAction =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
|
||||
if (uri == null) return@registerForActivityResult
|
||||
|
||||
d { "Selected repository URI is $uri" }
|
||||
// TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile
|
||||
val docId = DocumentsContract.getTreeDocumentId(uri)
|
||||
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
val path = if (split.size > 1) split[1] else split[0]
|
||||
val repoPath = "${Environment.getExternalStorageDirectory()}/$path"
|
||||
val prefs = sharedPrefs
|
||||
d { "Selected repository URI is $uri" }
|
||||
// TODO: This is fragile. Workaround until PasswordItem is backed by DocumentFile
|
||||
val docId = DocumentsContract.getTreeDocumentId(uri)
|
||||
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
val path = if (split.size > 1) split[1] else split[0]
|
||||
val repoPath = "${Environment.getExternalStorageDirectory()}/$path"
|
||||
val prefs = sharedPrefs
|
||||
|
||||
d { "Selected repository path is $repoPath" }
|
||||
d { "Selected repository path is $repoPath" }
|
||||
|
||||
if (Environment.getExternalStorageDirectory().path == repoPath) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(resources.getString(R.string.sdcard_root_warning_title))
|
||||
.setMessage(resources.getString(R.string.sdcard_root_warning_message))
|
||||
.setPositiveButton(resources.getString(R.string.sdcard_root_warning_remove_everything)) { _, _ ->
|
||||
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) }
|
||||
}
|
||||
.setNegativeButton(R.string.dialog_cancel, null)
|
||||
.show()
|
||||
}
|
||||
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) }
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
if (Environment.getExternalStorageDirectory().path == repoPath) {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(resources.getString(R.string.sdcard_root_warning_title))
|
||||
.setMessage(resources.getString(R.string.sdcard_root_warning_message))
|
||||
.setPositiveButton(resources.getString(R.string.sdcard_root_warning_remove_everything)) { _, _ ->
|
||||
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) }
|
||||
}
|
||||
.setNegativeButton(R.string.dialog_cancel, null)
|
||||
.show()
|
||||
}
|
||||
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, repoPath) }
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
directorySelectAction.launch(null)
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
directorySelectAction.launch(null)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,83 +22,85 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
|||
|
||||
class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider {
|
||||
|
||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||
builder.apply {
|
||||
val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
|
||||
val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
|
||||
val themeItems = themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
|
||||
singleChoice(PreferenceKeys.APP_THEME, themeItems) {
|
||||
initialSelection = activity.resources.getString(R.string.app_theme_def)
|
||||
titleRes = R.string.pref_app_theme_title
|
||||
}
|
||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||
builder.apply {
|
||||
val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
|
||||
val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
|
||||
val themeItems = themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
|
||||
singleChoice(PreferenceKeys.APP_THEME, themeItems) {
|
||||
initialSelection = activity.resources.getString(R.string.app_theme_def)
|
||||
titleRes = R.string.pref_app_theme_title
|
||||
}
|
||||
|
||||
val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
|
||||
val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
|
||||
val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
|
||||
singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
|
||||
initialSelection = sortValues[0]
|
||||
titleRes = R.string.pref_sort_order_title
|
||||
}
|
||||
val sortValues = activity.resources.getStringArray(R.array.sort_order_values)
|
||||
val sortOptions = activity.resources.getStringArray(R.array.sort_order_entries)
|
||||
val sortItems = sortValues.zip(sortOptions).map { SelectionItem(it.first, it.second, null) }
|
||||
singleChoice(PreferenceKeys.SORT_ORDER, sortItems) {
|
||||
initialSelection = sortValues[0]
|
||||
titleRes = R.string.pref_sort_order_title
|
||||
}
|
||||
|
||||
checkBox(PreferenceKeys.FILTER_RECURSIVELY) {
|
||||
titleRes = R.string.pref_recursive_filter_title
|
||||
summaryRes = R.string.pref_recursive_filter_summary
|
||||
defaultValue = true
|
||||
}
|
||||
checkBox(PreferenceKeys.FILTER_RECURSIVELY) {
|
||||
titleRes = R.string.pref_recursive_filter_title
|
||||
summaryRes = R.string.pref_recursive_filter_summary
|
||||
defaultValue = true
|
||||
}
|
||||
|
||||
checkBox(PreferenceKeys.SEARCH_ON_START) {
|
||||
titleRes = R.string.pref_search_on_start_title
|
||||
summaryRes = R.string.pref_search_on_start_summary
|
||||
defaultValue = false
|
||||
}
|
||||
checkBox(PreferenceKeys.SEARCH_ON_START) {
|
||||
titleRes = R.string.pref_search_on_start_title
|
||||
summaryRes = R.string.pref_search_on_start_summary
|
||||
defaultValue = false
|
||||
}
|
||||
|
||||
checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
|
||||
titleRes = R.string.pref_show_hidden_title
|
||||
summaryRes = R.string.pref_show_hidden_summary
|
||||
defaultValue = false
|
||||
}
|
||||
checkBox(PreferenceKeys.SHOW_HIDDEN_CONTENTS) {
|
||||
titleRes = R.string.pref_show_hidden_title
|
||||
summaryRes = R.string.pref_show_hidden_summary
|
||||
defaultValue = false
|
||||
}
|
||||
|
||||
checkBox(PreferenceKeys.BIOMETRIC_AUTH) {
|
||||
titleRes = R.string.pref_biometric_auth_title
|
||||
defaultValue = false
|
||||
}.apply {
|
||||
val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
|
||||
if (!canAuthenticate) {
|
||||
enabled = false
|
||||
checked = false
|
||||
summaryRes = R.string.pref_biometric_auth_summary_error
|
||||
} else {
|
||||
summaryRes = R.string.pref_biometric_auth_summary
|
||||
onClick {
|
||||
enabled = false
|
||||
val isChecked = checked
|
||||
activity.sharedPrefs.edit {
|
||||
BiometricAuthenticator.authenticate(activity) { result ->
|
||||
when (result) {
|
||||
is BiometricAuthenticator.Result.Success -> {
|
||||
// Apply the changes
|
||||
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked)
|
||||
enabled = true
|
||||
}
|
||||
else -> {
|
||||
// If any error occurs, revert back to the previous state. This
|
||||
// catch-all clause includes the cancellation case.
|
||||
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked)
|
||||
checked = !isChecked
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
activity.getSystemService<ShortcutManager>()?.apply {
|
||||
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
|
||||
}
|
||||
}
|
||||
false
|
||||
checkBox(PreferenceKeys.BIOMETRIC_AUTH) {
|
||||
titleRes = R.string.pref_biometric_auth_title
|
||||
defaultValue = false
|
||||
}
|
||||
.apply {
|
||||
val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
|
||||
if (!canAuthenticate) {
|
||||
enabled = false
|
||||
checked = false
|
||||
summaryRes = R.string.pref_biometric_auth_summary_error
|
||||
} else {
|
||||
summaryRes = R.string.pref_biometric_auth_summary
|
||||
onClick {
|
||||
enabled = false
|
||||
val isChecked = checked
|
||||
activity.sharedPrefs.edit {
|
||||
BiometricAuthenticator.authenticate(activity) { result ->
|
||||
when (result) {
|
||||
is BiometricAuthenticator.Result.Success -> {
|
||||
// Apply the changes
|
||||
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, checked)
|
||||
enabled = true
|
||||
}
|
||||
else -> {
|
||||
// If any error occurs, revert back to the previous
|
||||
// state. This
|
||||
// catch-all clause includes the cancellation case.
|
||||
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked)
|
||||
checked = !isChecked
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
activity.getSystemService<ShortcutManager>()?.apply {
|
||||
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,54 +23,59 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
|||
|
||||
class MiscSettings(activity: FragmentActivity) : SettingsProvider {
|
||||
|
||||
private val storeExportAction = activity.registerForActivityResult(object : ActivityResultContracts.OpenDocumentTree() {
|
||||
private val storeExportAction =
|
||||
activity.registerForActivityResult(
|
||||
object : ActivityResultContracts.OpenDocumentTree() {
|
||||
override fun createIntent(context: Context, input: Uri?): Intent {
|
||||
return super.createIntent(context, input).apply {
|
||||
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||
}
|
||||
return super.createIntent(context, input).apply {
|
||||
flags =
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
|
||||
}
|
||||
}
|
||||
}) { uri: Uri? ->
|
||||
if (uri == null) return@registerForActivityResult
|
||||
val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
|
||||
}
|
||||
) { uri: Uri? ->
|
||||
if (uri == null) return@registerForActivityResult
|
||||
val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
|
||||
|
||||
if (targetDirectory != null) {
|
||||
val service = Intent(activity.applicationContext, PasswordExportService::class.java).apply {
|
||||
action = PasswordExportService.ACTION_EXPORT_PASSWORD
|
||||
putExtra("uri", uri)
|
||||
}
|
||||
if (targetDirectory != null) {
|
||||
val service =
|
||||
Intent(activity.applicationContext, PasswordExportService::class.java).apply {
|
||||
action = PasswordExportService.ACTION_EXPORT_PASSWORD
|
||||
putExtra("uri", uri)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
activity.startForegroundService(service)
|
||||
} else {
|
||||
activity.startService(service)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
activity.startForegroundService(service)
|
||||
} else {
|
||||
activity.startService(service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||
builder.apply {
|
||||
pref(PreferenceKeys.EXPORT_PASSWORDS) {
|
||||
titleRes = R.string.prefs_export_passwords_title
|
||||
summaryRes = R.string.prefs_export_passwords_summary
|
||||
onClick {
|
||||
storeExportAction.launch(null)
|
||||
true
|
||||
}
|
||||
}
|
||||
checkBox(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) {
|
||||
defaultValue = false
|
||||
titleRes = R.string.pref_clear_clipboard_title
|
||||
summaryRes = R.string.pref_clear_clipboard_summary
|
||||
}
|
||||
checkBox(PreferenceKeys.ENABLE_DEBUG_LOGGING) {
|
||||
defaultValue = false
|
||||
titleRes = R.string.pref_debug_logging_title
|
||||
summaryRes = R.string.pref_debug_logging_summary
|
||||
visible = !BuildConfig.DEBUG
|
||||
}
|
||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||
builder.apply {
|
||||
pref(PreferenceKeys.EXPORT_PASSWORDS) {
|
||||
titleRes = R.string.prefs_export_passwords_title
|
||||
summaryRes = R.string.prefs_export_passwords_summary
|
||||
onClick {
|
||||
storeExportAction.launch(null)
|
||||
true
|
||||
}
|
||||
}
|
||||
checkBox(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY) {
|
||||
defaultValue = false
|
||||
titleRes = R.string.pref_clear_clipboard_title
|
||||
summaryRes = R.string.pref_clear_clipboard_summary
|
||||
}
|
||||
checkBox(PreferenceKeys.ENABLE_DEBUG_LOGGING) {
|
||||
defaultValue = false
|
||||
titleRes = R.string.pref_debug_logging_title
|
||||
summaryRes = R.string.pref_debug_logging_summary
|
||||
visible = !BuildConfig.DEBUG
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,85 +29,90 @@ import java.io.File
|
|||
|
||||
class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider {
|
||||
|
||||
private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs }
|
||||
private val storeCustomXkpwdDictionaryAction = activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@registerForActivityResult
|
||||
private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs }
|
||||
private val storeCustomXkpwdDictionaryAction =
|
||||
activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
if (uri == null) return@registerForActivityResult
|
||||
|
||||
Toast.makeText(
|
||||
activity,
|
||||
activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
Toast.makeText(
|
||||
activity,
|
||||
activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
|
||||
sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) }
|
||||
sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) }
|
||||
|
||||
val inputStream = activity.contentResolver.openInputStream(uri)
|
||||
val customDictFile = File(activity.filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream()
|
||||
inputStream?.copyTo(customDictFile, 1024)
|
||||
inputStream?.close()
|
||||
customDictFile.close()
|
||||
val inputStream = activity.contentResolver.openInputStream(uri)
|
||||
val customDictFile = File(activity.filesDir.toString(), XkpwdDictionary.XKPWD_CUSTOM_DICT_FILE).outputStream()
|
||||
inputStream?.copyTo(customDictFile, 1024)
|
||||
inputStream?.close()
|
||||
customDictFile.close()
|
||||
}
|
||||
|
||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||
builder.apply {
|
||||
val customDictPref = CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply {
|
||||
titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title
|
||||
summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off
|
||||
summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on
|
||||
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
|
||||
onCheckedChange {
|
||||
requestRebind()
|
||||
true
|
||||
}
|
||||
}
|
||||
val customDictPathPref = Preference(PreferenceKeys.PREF_KEY_CUSTOM_DICT).apply {
|
||||
dependency = PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT
|
||||
titleRes = R.string.pref_xkpwgen_custom_dict_picker_title
|
||||
summary = sharedPrefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
|
||||
?: activity.resources.getString(R.string.pref_xkpwgen_custom_dict_picker_summary)
|
||||
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
|
||||
onClick {
|
||||
storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*"))
|
||||
true
|
||||
}
|
||||
}
|
||||
val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
|
||||
val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels)
|
||||
val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) }
|
||||
singleChoice(
|
||||
PreferenceKeys.PREF_KEY_PWGEN_TYPE,
|
||||
items,
|
||||
) {
|
||||
initialSelection = "classic"
|
||||
titleRes = R.string.pref_password_generator_type_title
|
||||
onSelectionChange { selection ->
|
||||
val xkpasswdEnabled = selection == "xkpasswd"
|
||||
customDictPathPref.visible = xkpasswdEnabled
|
||||
customDictPref.visible = xkpasswdEnabled
|
||||
customDictPref.requestRebind()
|
||||
customDictPathPref.requestRebind()
|
||||
true
|
||||
}
|
||||
}
|
||||
// We initialize them early and add them manually to be able to manually force a rebind
|
||||
// when the password generator type is changed.
|
||||
addPreferenceItem(customDictPref)
|
||||
addPreferenceItem(customDictPathPref)
|
||||
editText(PreferenceKeys.GENERAL_SHOW_TIME) {
|
||||
titleRes = R.string.pref_clipboard_timeout_title
|
||||
summaryProvider = { activity.getString(R.string.pref_clipboard_timeout_summary) }
|
||||
textInputType = InputType.TYPE_CLASS_NUMBER
|
||||
}
|
||||
checkBox(PreferenceKeys.SHOW_PASSWORD) {
|
||||
titleRes = R.string.show_password_pref_title
|
||||
summaryRes = R.string.show_password_pref_summary
|
||||
defaultValue = true
|
||||
}
|
||||
checkBox(PreferenceKeys.COPY_ON_DECRYPT) {
|
||||
titleRes = R.string.pref_copy_title
|
||||
summaryRes = R.string.pref_copy_summary
|
||||
defaultValue = false
|
||||
}
|
||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||
builder.apply {
|
||||
val customDictPref =
|
||||
CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply {
|
||||
titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title
|
||||
summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off
|
||||
summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on
|
||||
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
|
||||
onCheckedChange {
|
||||
requestRebind()
|
||||
true
|
||||
}
|
||||
}
|
||||
val customDictPathPref =
|
||||
Preference(PreferenceKeys.PREF_KEY_CUSTOM_DICT).apply {
|
||||
dependency = PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT
|
||||
titleRes = R.string.pref_xkpwgen_custom_dict_picker_title
|
||||
summary =
|
||||
sharedPrefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
|
||||
?: activity.resources.getString(R.string.pref_xkpwgen_custom_dict_picker_summary)
|
||||
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
|
||||
onClick {
|
||||
storeCustomXkpwdDictionaryAction.launch(arrayOf("*/*"))
|
||||
true
|
||||
}
|
||||
}
|
||||
val values = activity.resources.getStringArray(R.array.pwgen_provider_values)
|
||||
val labels = activity.resources.getStringArray(R.array.pwgen_provider_labels)
|
||||
val items = values.zip(labels).map { SelectionItem(it.first, it.second, null) }
|
||||
singleChoice(
|
||||
PreferenceKeys.PREF_KEY_PWGEN_TYPE,
|
||||
items,
|
||||
) {
|
||||
initialSelection = "classic"
|
||||
titleRes = R.string.pref_password_generator_type_title
|
||||
onSelectionChange { selection ->
|
||||
val xkpasswdEnabled = selection == "xkpasswd"
|
||||
customDictPathPref.visible = xkpasswdEnabled
|
||||
customDictPref.visible = xkpasswdEnabled
|
||||
customDictPref.requestRebind()
|
||||
customDictPathPref.requestRebind()
|
||||
true
|
||||
}
|
||||
}
|
||||
// We initialize them early and add them manually to be able to manually force a rebind
|
||||
// when the password generator type is changed.
|
||||
addPreferenceItem(customDictPref)
|
||||
addPreferenceItem(customDictPathPref)
|
||||
editText(PreferenceKeys.GENERAL_SHOW_TIME) {
|
||||
titleRes = R.string.pref_clipboard_timeout_title
|
||||
summaryProvider = { activity.getString(R.string.pref_clipboard_timeout_summary) }
|
||||
textInputType = InputType.TYPE_CLASS_NUMBER
|
||||
}
|
||||
checkBox(PreferenceKeys.SHOW_PASSWORD) {
|
||||
titleRes = R.string.show_password_pref_title
|
||||
summaryRes = R.string.show_password_pref_summary
|
||||
defaultValue = true
|
||||
}
|
||||
checkBox(PreferenceKeys.COPY_ON_DECRYPT) {
|
||||
titleRes = R.string.pref_copy_title
|
||||
summaryRes = R.string.pref_copy_summary
|
||||
defaultValue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,168 +37,165 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
|||
|
||||
class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider {
|
||||
|
||||
private val encryptedPreferences by lazy(LazyThreadSafetyMode.NONE) { activity.getEncryptedGitPrefs() }
|
||||
private val encryptedPreferences by lazy(LazyThreadSafetyMode.NONE) { activity.getEncryptedGitPrefs() }
|
||||
|
||||
private fun <T : FragmentActivity> launchActivity(clazz: Class<T>) {
|
||||
activity.startActivity(Intent(activity, clazz))
|
||||
}
|
||||
private fun <T : FragmentActivity> launchActivity(clazz: Class<T>) {
|
||||
activity.startActivity(Intent(activity, clazz))
|
||||
}
|
||||
|
||||
private fun selectExternalGitRepository() {
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(activity.resources.getString(R.string.external_repository_dialog_title))
|
||||
.setMessage(activity.resources.getString(R.string.external_repository_dialog_text))
|
||||
.setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||
launchActivity(DirectorySelectionActivity::class.java)
|
||||
}
|
||||
.setNegativeButton(R.string.dialog_cancel, null)
|
||||
.show()
|
||||
}
|
||||
private fun selectExternalGitRepository() {
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(activity.resources.getString(R.string.external_repository_dialog_title))
|
||||
.setMessage(activity.resources.getString(R.string.external_repository_dialog_text))
|
||||
.setPositiveButton(R.string.dialog_ok) { _, _ -> launchActivity(DirectorySelectionActivity::class.java) }
|
||||
.setNegativeButton(R.string.dialog_cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||
builder.apply {
|
||||
checkBox(PreferenceKeys.REBASE_ON_PULL) {
|
||||
titleRes = R.string.pref_rebase_on_pull_title
|
||||
summaryRes = R.string.pref_rebase_on_pull_summary
|
||||
summaryOnRes = R.string.pref_rebase_on_pull_summary_on
|
||||
defaultValue = true
|
||||
}
|
||||
pref(PreferenceKeys.GIT_SERVER_INFO) {
|
||||
titleRes = R.string.pref_edit_git_server_settings
|
||||
visible = PasswordRepository.isGitRepo()
|
||||
onClick {
|
||||
launchActivity(GitServerConfigActivity::class.java)
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.PROXY_SETTINGS) {
|
||||
titleRes = R.string.pref_edit_proxy_settings
|
||||
visible = GitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo()
|
||||
onClick {
|
||||
launchActivity(ProxySelectorActivity::class.java)
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.GIT_CONFIG) {
|
||||
titleRes = R.string.pref_edit_git_config
|
||||
visible = PasswordRepository.isGitRepo()
|
||||
onClick {
|
||||
launchActivity(GitConfigActivity::class.java)
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.SSH_KEY) {
|
||||
titleRes = R.string.pref_import_ssh_key_title
|
||||
visible = PasswordRepository.isGitRepo()
|
||||
onClick {
|
||||
launchActivity(SshKeyImportActivity::class.java)
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.SSH_KEYGEN) {
|
||||
titleRes = R.string.pref_ssh_keygen_title
|
||||
onClick {
|
||||
launchActivity(SshKeyGenActivity::class.java)
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.SSH_SEE_KEY) {
|
||||
titleRes = R.string.pref_ssh_see_key_title
|
||||
visible = PasswordRepository.isGitRepo()
|
||||
onClick {
|
||||
ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.CLEAR_SAVED_PASS) {
|
||||
fun Preference.updatePref() {
|
||||
val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
|
||||
val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
|
||||
if (sshPass == null && httpsPass == null) {
|
||||
visible = false
|
||||
return
|
||||
}
|
||||
when {
|
||||
httpsPass != null -> titleRes = R.string.clear_saved_passphrase_https
|
||||
sshPass != null -> titleRes = R.string.clear_saved_passphrase_ssh
|
||||
}
|
||||
visible = true
|
||||
requestRebind()
|
||||
}
|
||||
onClick {
|
||||
updatePref()
|
||||
true
|
||||
}
|
||||
updatePref()
|
||||
}
|
||||
pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
|
||||
titleRes = R.string.pref_title_openkeystore_clear_keyid
|
||||
visible = activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty()
|
||||
?: false
|
||||
onClick {
|
||||
activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
|
||||
visible = false
|
||||
true
|
||||
}
|
||||
}
|
||||
val deleteRepoPref = pref(PreferenceKeys.GIT_DELETE_REPO) {
|
||||
titleRes = R.string.pref_git_delete_repo_title
|
||||
summaryRes = R.string.pref_git_delete_repo_summary
|
||||
visible = !activity.sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
||||
onClick {
|
||||
val repoDir = PasswordRepository.getRepositoryDirectory()
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.pref_dialog_delete_title)
|
||||
.setMessage(activity.getString(R.string.dialog_delete_msg, repoDir))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.dialog_delete) { dialogInterface, _ ->
|
||||
runCatching {
|
||||
PasswordRepository.getRepositoryDirectory().deleteRecursively()
|
||||
PasswordRepository.closeRepository()
|
||||
}.onFailure {
|
||||
it.message?.let { message ->
|
||||
activity.snackbar(message = message)
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
activity.getSystemService<ShortcutManager>()?.apply {
|
||||
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
|
||||
}
|
||||
}
|
||||
activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
|
||||
dialogInterface.cancel()
|
||||
activity.finish()
|
||||
}
|
||||
.setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> run { dialogInterface.cancel() } }
|
||||
.show()
|
||||
true
|
||||
}
|
||||
}
|
||||
checkBox(PreferenceKeys.GIT_EXTERNAL) {
|
||||
titleRes = R.string.pref_external_repository_title
|
||||
summaryRes = R.string.pref_external_repository_summary
|
||||
onCheckedChange { checked ->
|
||||
deleteRepoPref.visible = !checked
|
||||
deleteRepoPref.requestRebind()
|
||||
PasswordRepository.closeRepository()
|
||||
activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) }
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.GIT_EXTERNAL_REPO) {
|
||||
val externalRepo = activity.sharedPrefs.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
if (externalRepo != null) {
|
||||
summary = externalRepo
|
||||
} else {
|
||||
summaryRes = R.string.pref_select_external_repository_summary_no_repo_selected
|
||||
}
|
||||
titleRes = R.string.pref_select_external_repository_title
|
||||
dependency = PreferenceKeys.GIT_EXTERNAL
|
||||
onClick {
|
||||
selectExternalGitRepository()
|
||||
true
|
||||
}
|
||||
}
|
||||
override fun provideSettings(builder: PreferenceScreen.Builder) {
|
||||
builder.apply {
|
||||
checkBox(PreferenceKeys.REBASE_ON_PULL) {
|
||||
titleRes = R.string.pref_rebase_on_pull_title
|
||||
summaryRes = R.string.pref_rebase_on_pull_summary
|
||||
summaryOnRes = R.string.pref_rebase_on_pull_summary_on
|
||||
defaultValue = true
|
||||
}
|
||||
pref(PreferenceKeys.GIT_SERVER_INFO) {
|
||||
titleRes = R.string.pref_edit_git_server_settings
|
||||
visible = PasswordRepository.isGitRepo()
|
||||
onClick {
|
||||
launchActivity(GitServerConfigActivity::class.java)
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.PROXY_SETTINGS) {
|
||||
titleRes = R.string.pref_edit_proxy_settings
|
||||
visible = GitSettings.url?.startsWith("https") == true && PasswordRepository.isGitRepo()
|
||||
onClick {
|
||||
launchActivity(ProxySelectorActivity::class.java)
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.GIT_CONFIG) {
|
||||
titleRes = R.string.pref_edit_git_config
|
||||
visible = PasswordRepository.isGitRepo()
|
||||
onClick {
|
||||
launchActivity(GitConfigActivity::class.java)
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.SSH_KEY) {
|
||||
titleRes = R.string.pref_import_ssh_key_title
|
||||
visible = PasswordRepository.isGitRepo()
|
||||
onClick {
|
||||
launchActivity(SshKeyImportActivity::class.java)
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.SSH_KEYGEN) {
|
||||
titleRes = R.string.pref_ssh_keygen_title
|
||||
onClick {
|
||||
launchActivity(SshKeyGenActivity::class.java)
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.SSH_SEE_KEY) {
|
||||
titleRes = R.string.pref_ssh_see_key_title
|
||||
visible = PasswordRepository.isGitRepo()
|
||||
onClick {
|
||||
ShowSshKeyFragment().show(activity.supportFragmentManager, "public_key")
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.CLEAR_SAVED_PASS) {
|
||||
fun Preference.updatePref() {
|
||||
val sshPass = encryptedPreferences.getString(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
|
||||
val httpsPass = encryptedPreferences.getString(PreferenceKeys.HTTPS_PASSWORD)
|
||||
if (sshPass == null && httpsPass == null) {
|
||||
visible = false
|
||||
return
|
||||
}
|
||||
when {
|
||||
httpsPass != null -> titleRes = R.string.clear_saved_passphrase_https
|
||||
sshPass != null -> titleRes = R.string.clear_saved_passphrase_ssh
|
||||
}
|
||||
visible = true
|
||||
requestRebind()
|
||||
}
|
||||
onClick {
|
||||
updatePref()
|
||||
true
|
||||
}
|
||||
updatePref()
|
||||
}
|
||||
pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
|
||||
titleRes = R.string.pref_title_openkeystore_clear_keyid
|
||||
visible = activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() ?: false
|
||||
onClick {
|
||||
activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
|
||||
visible = false
|
||||
true
|
||||
}
|
||||
}
|
||||
val deleteRepoPref =
|
||||
pref(PreferenceKeys.GIT_DELETE_REPO) {
|
||||
titleRes = R.string.pref_git_delete_repo_title
|
||||
summaryRes = R.string.pref_git_delete_repo_summary
|
||||
visible = !activity.sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
|
||||
onClick {
|
||||
val repoDir = PasswordRepository.getRepositoryDirectory()
|
||||
MaterialAlertDialogBuilder(activity)
|
||||
.setTitle(R.string.pref_dialog_delete_title)
|
||||
.setMessage(activity.getString(R.string.dialog_delete_msg, repoDir))
|
||||
.setCancelable(false)
|
||||
.setPositiveButton(R.string.dialog_delete) { dialogInterface, _ ->
|
||||
runCatching {
|
||||
PasswordRepository.getRepositoryDirectory().deleteRecursively()
|
||||
PasswordRepository.closeRepository()
|
||||
}
|
||||
.onFailure { it.message?.let { message -> activity.snackbar(message = message) } }
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
activity.getSystemService<ShortcutManager>()?.apply {
|
||||
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
|
||||
}
|
||||
}
|
||||
activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
|
||||
dialogInterface.cancel()
|
||||
activity.finish()
|
||||
}
|
||||
.setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ ->
|
||||
run { dialogInterface.cancel() }
|
||||
}
|
||||
.show()
|
||||
true
|
||||
}
|
||||
}
|
||||
checkBox(PreferenceKeys.GIT_EXTERNAL) {
|
||||
titleRes = R.string.pref_external_repository_title
|
||||
summaryRes = R.string.pref_external_repository_summary
|
||||
onCheckedChange { checked ->
|
||||
deleteRepoPref.visible = !checked
|
||||
deleteRepoPref.requestRebind()
|
||||
PasswordRepository.closeRepository()
|
||||
activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPO_CHANGED, true) }
|
||||
true
|
||||
}
|
||||
}
|
||||
pref(PreferenceKeys.GIT_EXTERNAL_REPO) {
|
||||
val externalRepo = activity.sharedPrefs.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
|
||||
if (externalRepo != null) {
|
||||
summary = externalRepo
|
||||
} else {
|
||||
summaryRes = R.string.pref_select_external_repository_summary_no_repo_selected
|
||||
}
|
||||
titleRes = R.string.pref_select_external_repository_title
|
||||
dependency = PreferenceKeys.GIT_EXTERNAL
|
||||
onClick {
|
||||
selectExternalGitRepository()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,77 +17,79 @@ import dev.msfjarvis.aps.util.extensions.viewBinding
|
|||
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
private val miscSettings = MiscSettings(this)
|
||||
private val autofillSettings = AutofillSettings(this)
|
||||
private val passwordSettings = PasswordSettings(this)
|
||||
private val repositorySettings = RepositorySettings(this)
|
||||
private val generalSettings = GeneralSettings(this)
|
||||
private val miscSettings = MiscSettings(this)
|
||||
private val autofillSettings = AutofillSettings(this)
|
||||
private val passwordSettings = PasswordSettings(this)
|
||||
private val repositorySettings = RepositorySettings(this)
|
||||
private val generalSettings = GeneralSettings(this)
|
||||
|
||||
private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
|
||||
private val preferencesAdapter: PreferencesAdapter
|
||||
get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
|
||||
private val binding by viewBinding(ActivityPreferenceRecyclerviewBinding::inflate)
|
||||
private val preferencesAdapter: PreferencesAdapter
|
||||
get() = binding.preferenceRecyclerView.adapter as PreferencesAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
val screen = screen(this) {
|
||||
subScreen {
|
||||
titleRes = R.string.pref_category_general_title
|
||||
iconRes = R.drawable.app_settings_alt_24px
|
||||
generalSettings.provideSettings(this)
|
||||
}
|
||||
subScreen {
|
||||
titleRes = R.string.pref_category_autofill_title
|
||||
iconRes = R.drawable.ic_wysiwyg_24px
|
||||
autofillSettings.provideSettings(this)
|
||||
}
|
||||
subScreen {
|
||||
titleRes = R.string.pref_category_passwords_title
|
||||
iconRes = R.drawable.ic_lock_open_24px
|
||||
passwordSettings.provideSettings(this)
|
||||
}
|
||||
subScreen {
|
||||
titleRes = R.string.pref_category_repository_title
|
||||
iconRes = R.drawable.ic_call_merge_24px
|
||||
repositorySettings.provideSettings(this)
|
||||
}
|
||||
subScreen {
|
||||
titleRes = R.string.pref_category_misc_title
|
||||
iconRes = R.drawable.ic_miscellaneous_services_24px
|
||||
miscSettings.provideSettings(this)
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
val screen =
|
||||
screen(this) {
|
||||
subScreen {
|
||||
titleRes = R.string.pref_category_general_title
|
||||
iconRes = R.drawable.app_settings_alt_24px
|
||||
generalSettings.provideSettings(this)
|
||||
}
|
||||
val adapter = PreferencesAdapter(screen)
|
||||
adapter.onScreenChangeListener = PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
|
||||
supportActionBar?.title = if (!entering) {
|
||||
getString(R.string.action_settings)
|
||||
} else {
|
||||
getString(subScreen.titleRes)
|
||||
}
|
||||
subScreen {
|
||||
titleRes = R.string.pref_category_autofill_title
|
||||
iconRes = R.drawable.ic_wysiwyg_24px
|
||||
autofillSettings.provideSettings(this)
|
||||
}
|
||||
savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter")
|
||||
?.let(adapter::loadSavedState)
|
||||
binding.preferenceRecyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putParcelable("adapter", preferencesAdapter.getSavedState())
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> if (!preferencesAdapter.goBack()) {
|
||||
super.onOptionsItemSelected(item)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
subScreen {
|
||||
titleRes = R.string.pref_category_passwords_title
|
||||
iconRes = R.drawable.ic_lock_open_24px
|
||||
passwordSettings.provideSettings(this)
|
||||
}
|
||||
}
|
||||
subScreen {
|
||||
titleRes = R.string.pref_category_repository_title
|
||||
iconRes = R.drawable.ic_call_merge_24px
|
||||
repositorySettings.provideSettings(this)
|
||||
}
|
||||
subScreen {
|
||||
titleRes = R.string.pref_category_misc_title
|
||||
iconRes = R.drawable.ic_miscellaneous_services_24px
|
||||
miscSettings.provideSettings(this)
|
||||
}
|
||||
}
|
||||
val adapter = PreferencesAdapter(screen)
|
||||
adapter.onScreenChangeListener =
|
||||
PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
|
||||
supportActionBar?.title =
|
||||
if (!entering) {
|
||||
getString(R.string.action_settings)
|
||||
} else {
|
||||
getString(subScreen.titleRes)
|
||||
}
|
||||
}
|
||||
savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter")?.let(adapter::loadSavedState)
|
||||
binding.preferenceRecyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (!preferencesAdapter.goBack())
|
||||
super.onBackPressed()
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putParcelable("adapter", preferencesAdapter.getSavedState())
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home ->
|
||||
if (!preferencesAdapter.goBack()) {
|
||||
super.onOptionsItemSelected(item)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackPressed() {
|
||||
if (!preferencesAdapter.goBack()) super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,13 +7,9 @@ package dev.msfjarvis.aps.ui.settings
|
|||
|
||||
import de.Maxr1998.modernpreferences.PreferenceScreen
|
||||
|
||||
/**
|
||||
* Used to generate a uniform API for all settings UI classes.
|
||||
*/
|
||||
/** Used to generate a uniform API for all settings UI classes. */
|
||||
interface SettingsProvider {
|
||||
|
||||
/**
|
||||
* Inserts the settings items for the class into the given [builder].
|
||||
*/
|
||||
fun provideSettings(builder: PreferenceScreen.Builder)
|
||||
/** Inserts the settings items for the class into the given [builder]. */
|
||||
fun provideSettings(builder: PreferenceScreen.Builder)
|
||||
}
|
||||
|
|
|
@ -14,25 +14,24 @@ import dev.msfjarvis.aps.util.git.sshj.SshKey
|
|||
|
||||
class ShowSshKeyFragment : DialogFragment() {
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val activity = requireActivity()
|
||||
val publicKey = SshKey.sshPublicKey
|
||||
return MaterialAlertDialogBuilder(requireActivity()).run {
|
||||
setMessage(getString(R.string.ssh_keygen_message, publicKey))
|
||||
setTitle(R.string.your_public_key)
|
||||
setNegativeButton(R.string.ssh_keygen_later) { _, _ ->
|
||||
(activity as? SshKeyGenActivity)?.finish()
|
||||
}
|
||||
setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
|
||||
val sendIntent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, publicKey)
|
||||
}
|
||||
startActivity(Intent.createChooser(sendIntent, null))
|
||||
(activity as? SshKeyGenActivity)?.finish()
|
||||
}
|
||||
create()
|
||||
}
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val activity = requireActivity()
|
||||
val publicKey = SshKey.sshPublicKey
|
||||
return MaterialAlertDialogBuilder(requireActivity()).run {
|
||||
setMessage(getString(R.string.ssh_keygen_message, publicKey))
|
||||
setTitle(R.string.your_public_key)
|
||||
setNegativeButton(R.string.ssh_keygen_later) { _, _ -> (activity as? SshKeyGenActivity)?.finish() }
|
||||
setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
|
||||
val sendIntent =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, publicKey)
|
||||
}
|
||||
startActivity(Intent.createChooser(sendIntent, null))
|
||||
(activity as? SshKeyGenActivity)?.finish()
|
||||
}
|
||||
create()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,135 +30,122 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
|
||||
private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) {
|
||||
Rsa({ requireAuthentication ->
|
||||
SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication)
|
||||
}),
|
||||
Ecdsa({ requireAuthentication ->
|
||||
SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication)
|
||||
}),
|
||||
Ed25519({ requireAuthentication ->
|
||||
SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication)
|
||||
}),
|
||||
Rsa({ requireAuthentication -> SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) }),
|
||||
Ecdsa({ requireAuthentication -> SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) }),
|
||||
Ed25519({ requireAuthentication -> SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) }),
|
||||
}
|
||||
|
||||
class SshKeyGenActivity : AppCompatActivity() {
|
||||
|
||||
private var keyGenType = KeyGenType.Ecdsa
|
||||
private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
|
||||
private var keyGenType = KeyGenType.Ecdsa
|
||||
private val binding by viewBinding(ActivitySshKeygenBinding::inflate)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
with(binding) {
|
||||
generate.setOnClickListener {
|
||||
if (SshKey.exists) {
|
||||
MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
|
||||
setTitle(R.string.ssh_keygen_existing_title)
|
||||
setMessage(R.string.ssh_keygen_existing_message)
|
||||
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
|
||||
lifecycleScope.launch {
|
||||
generate()
|
||||
}
|
||||
}
|
||||
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ ->
|
||||
finish()
|
||||
}
|
||||
show()
|
||||
}
|
||||
} else {
|
||||
lifecycleScope.launch {
|
||||
generate()
|
||||
}
|
||||
}
|
||||
}
|
||||
keyTypeGroup.check(R.id.key_type_ecdsa)
|
||||
keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
|
||||
keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (isChecked) {
|
||||
keyGenType = when (checkedId) {
|
||||
R.id.key_type_ed25519 -> KeyGenType.Ed25519
|
||||
R.id.key_type_ecdsa -> KeyGenType.Ecdsa
|
||||
R.id.key_type_rsa -> KeyGenType.Rsa
|
||||
else -> throw IllegalStateException("Impossible key type selection")
|
||||
}
|
||||
keyTypeExplanation.setText(when (keyGenType) {
|
||||
KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
|
||||
KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
|
||||
KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
|
||||
})
|
||||
}
|
||||
}
|
||||
keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure
|
||||
keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
with(binding) {
|
||||
generate.setOnClickListener {
|
||||
if (SshKey.exists) {
|
||||
MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
|
||||
setTitle(R.string.ssh_keygen_existing_title)
|
||||
setMessage(R.string.ssh_keygen_existing_message)
|
||||
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> lifecycleScope.launch { generate() } }
|
||||
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
|
||||
show()
|
||||
}
|
||||
} else {
|
||||
lifecycleScope.launch { generate() }
|
||||
}
|
||||
}
|
||||
keyTypeGroup.check(R.id.key_type_ecdsa)
|
||||
keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
|
||||
keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (isChecked) {
|
||||
keyGenType =
|
||||
when (checkedId) {
|
||||
R.id.key_type_ed25519 -> KeyGenType.Ed25519
|
||||
R.id.key_type_ecdsa -> KeyGenType.Ecdsa
|
||||
R.id.key_type_rsa -> KeyGenType.Rsa
|
||||
else -> throw IllegalStateException("Impossible key type selection")
|
||||
}
|
||||
keyTypeExplanation.setText(
|
||||
when (keyGenType) {
|
||||
KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
|
||||
KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
|
||||
KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure
|
||||
keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
onBackPressed()
|
||||
true
|
||||
}
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun generate() {
|
||||
binding.generate.apply {
|
||||
text = getString(R.string.ssh_key_gen_generating_progress)
|
||||
isEnabled = false
|
||||
}
|
||||
binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
|
||||
val result = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val requireAuthentication = binding.keyRequireAuthentication.isChecked
|
||||
if (requireAuthentication) {
|
||||
val result = withContext(Dispatchers.Main) {
|
||||
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
|
||||
BiometricAuthenticator.authenticate(this@SshKeyGenActivity, R.string.biometric_prompt_title_ssh_keygen) {
|
||||
cont.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result !is BiometricAuthenticator.Result.Success)
|
||||
throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure))
|
||||
}
|
||||
keyGenType.generateKey(requireAuthentication)
|
||||
private suspend fun generate() {
|
||||
binding.generate.apply {
|
||||
text = getString(R.string.ssh_key_gen_generating_progress)
|
||||
isEnabled = false
|
||||
}
|
||||
binding.generate.text = getString(R.string.ssh_key_gen_generating_progress)
|
||||
val result = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val requireAuthentication = binding.keyRequireAuthentication.isChecked
|
||||
if (requireAuthentication) {
|
||||
val result =
|
||||
withContext(Dispatchers.Main) {
|
||||
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
|
||||
BiometricAuthenticator.authenticate(
|
||||
this@SshKeyGenActivity,
|
||||
R.string.biometric_prompt_title_ssh_keygen
|
||||
) { cont.resume(it) }
|
||||
}
|
||||
}
|
||||
if (result !is BiometricAuthenticator.Result.Success)
|
||||
throw UserNotAuthenticatedException(getString(R.string.biometric_auth_generic_failure))
|
||||
}
|
||||
getEncryptedGitPrefs().edit {
|
||||
remove("ssh_key_local_passphrase")
|
||||
}
|
||||
binding.generate.apply {
|
||||
text = getString(R.string.ssh_keygen_generate)
|
||||
isEnabled = true
|
||||
}
|
||||
result.fold(
|
||||
success = {
|
||||
ShowSshKeyFragment().show(supportFragmentManager, "public_key")
|
||||
},
|
||||
failure = { e ->
|
||||
e.printStackTrace()
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.error_generate_ssh_key))
|
||||
.setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message)
|
||||
.setPositiveButton(getString(R.string.dialog_ok)) { _, _ ->
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
},
|
||||
)
|
||||
hideKeyboard()
|
||||
keyGenType.generateKey(requireAuthentication)
|
||||
}
|
||||
}
|
||||
getEncryptedGitPrefs().edit { remove("ssh_key_local_passphrase") }
|
||||
binding.generate.apply {
|
||||
text = getString(R.string.ssh_keygen_generate)
|
||||
isEnabled = true
|
||||
}
|
||||
result.fold(
|
||||
success = { ShowSshKeyFragment().show(supportFragmentManager, "public_key") },
|
||||
failure = { e ->
|
||||
e.printStackTrace()
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.error_generate_ssh_key))
|
||||
.setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message)
|
||||
.setPositiveButton(getString(R.string.dialog_ok)) { _, _ ->
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
.show()
|
||||
},
|
||||
)
|
||||
hideKeyboard()
|
||||
}
|
||||
|
||||
private fun hideKeyboard() {
|
||||
val imm = getSystemService<InputMethodManager>() ?: return
|
||||
var view = currentFocus
|
||||
if (view == null) {
|
||||
view = View(this)
|
||||
}
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
private fun hideKeyboard() {
|
||||
val imm = getSystemService<InputMethodManager>() ?: return
|
||||
var view = currentFocus
|
||||
if (view == null) {
|
||||
view = View(this)
|
||||
}
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,44 +18,44 @@ import dev.msfjarvis.aps.util.git.sshj.SshKey
|
|||
|
||||
class SshKeyImportActivity : AppCompatActivity() {
|
||||
|
||||
private val sshKeyImportAction = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
|
||||
if (uri == null) {
|
||||
finish()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
runCatching {
|
||||
SshKey.import(uri)
|
||||
Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show()
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}.onFailure { e ->
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
|
||||
.setMessage(e.message)
|
||||
.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() }
|
||||
.show()
|
||||
private val sshKeyImportAction =
|
||||
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
|
||||
if (uri == null) {
|
||||
finish()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
runCatching {
|
||||
SshKey.import(uri)
|
||||
Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show()
|
||||
setResult(RESULT_OK)
|
||||
finish()
|
||||
}
|
||||
.onFailure { e ->
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
|
||||
.setMessage(e.message)
|
||||
.setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> finish() }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (SshKey.exists) {
|
||||
MaterialAlertDialogBuilder(this).run {
|
||||
setTitle(R.string.ssh_keygen_existing_title)
|
||||
setMessage(R.string.ssh_keygen_existing_message)
|
||||
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
|
||||
importSshKey()
|
||||
}
|
||||
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
|
||||
setOnCancelListener { finish() }
|
||||
show()
|
||||
}
|
||||
} else {
|
||||
importSshKey()
|
||||
}
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (SshKey.exists) {
|
||||
MaterialAlertDialogBuilder(this).run {
|
||||
setTitle(R.string.ssh_keygen_existing_title)
|
||||
setMessage(R.string.ssh_keygen_existing_message)
|
||||
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() }
|
||||
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
|
||||
setOnCancelListener { finish() }
|
||||
show()
|
||||
}
|
||||
} else {
|
||||
importSshKey()
|
||||
}
|
||||
}
|
||||
|
||||
private fun importSshKey() {
|
||||
sshKeyImportAction.launch(arrayOf("*/*"))
|
||||
}
|
||||
private fun importSshKey() {
|
||||
sshKeyImportAction.launch(arrayOf("*/*"))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,63 +9,63 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
|
||||
class OnOffItemAnimator : DefaultItemAnimator() {
|
||||
|
||||
var isEnabled: Boolean = true
|
||||
set(value) {
|
||||
// Defer update until no animation is running anymore.
|
||||
isRunning { field = value }
|
||||
}
|
||||
|
||||
private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean {
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
return false
|
||||
var isEnabled: Boolean = true
|
||||
set(value) {
|
||||
// Defer update until no animation is running anymore.
|
||||
isRunning { field = value }
|
||||
}
|
||||
|
||||
override fun animateAppearance(
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
preLayoutInfo: ItemHolderInfo?,
|
||||
postLayoutInfo: ItemHolderInfo
|
||||
): Boolean {
|
||||
return if (isEnabled) {
|
||||
super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo)
|
||||
} else {
|
||||
dontAnimate(viewHolder)
|
||||
}
|
||||
}
|
||||
private fun dontAnimate(viewHolder: RecyclerView.ViewHolder): Boolean {
|
||||
dispatchAnimationFinished(viewHolder)
|
||||
return false
|
||||
}
|
||||
|
||||
override fun animateChange(
|
||||
oldHolder: RecyclerView.ViewHolder,
|
||||
newHolder: RecyclerView.ViewHolder,
|
||||
preInfo: ItemHolderInfo,
|
||||
postInfo: ItemHolderInfo
|
||||
): Boolean {
|
||||
return if (isEnabled) {
|
||||
super.animateChange(oldHolder, newHolder, preInfo, postInfo)
|
||||
} else {
|
||||
dontAnimate(oldHolder)
|
||||
}
|
||||
override fun animateAppearance(
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
preLayoutInfo: ItemHolderInfo?,
|
||||
postLayoutInfo: ItemHolderInfo
|
||||
): Boolean {
|
||||
return if (isEnabled) {
|
||||
super.animateAppearance(viewHolder, preLayoutInfo, postLayoutInfo)
|
||||
} else {
|
||||
dontAnimate(viewHolder)
|
||||
}
|
||||
}
|
||||
|
||||
override fun animateDisappearance(
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
preLayoutInfo: ItemHolderInfo,
|
||||
postLayoutInfo: ItemHolderInfo?
|
||||
): Boolean {
|
||||
return if (isEnabled) {
|
||||
super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo)
|
||||
} else {
|
||||
dontAnimate(viewHolder)
|
||||
}
|
||||
override fun animateChange(
|
||||
oldHolder: RecyclerView.ViewHolder,
|
||||
newHolder: RecyclerView.ViewHolder,
|
||||
preInfo: ItemHolderInfo,
|
||||
postInfo: ItemHolderInfo
|
||||
): Boolean {
|
||||
return if (isEnabled) {
|
||||
super.animateChange(oldHolder, newHolder, preInfo, postInfo)
|
||||
} else {
|
||||
dontAnimate(oldHolder)
|
||||
}
|
||||
}
|
||||
|
||||
override fun animatePersistence(
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
preInfo: ItemHolderInfo,
|
||||
postInfo: ItemHolderInfo
|
||||
): Boolean {
|
||||
return if (isEnabled) {
|
||||
super.animatePersistence(viewHolder, preInfo, postInfo)
|
||||
} else {
|
||||
dontAnimate(viewHolder)
|
||||
}
|
||||
override fun animateDisappearance(
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
preLayoutInfo: ItemHolderInfo,
|
||||
postLayoutInfo: ItemHolderInfo?
|
||||
): Boolean {
|
||||
return if (isEnabled) {
|
||||
super.animateDisappearance(viewHolder, preLayoutInfo, postLayoutInfo)
|
||||
} else {
|
||||
dontAnimate(viewHolder)
|
||||
}
|
||||
}
|
||||
|
||||
override fun animatePersistence(
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
preInfo: ItemHolderInfo,
|
||||
postInfo: ItemHolderInfo
|
||||
): Boolean {
|
||||
return if (isEnabled) {
|
||||
super.animatePersistence(viewHolder, preInfo, postInfo)
|
||||
} else {
|
||||
dontAnimate(viewHolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,61 +18,69 @@ import dev.msfjarvis.aps.R
|
|||
|
||||
object BiometricAuthenticator {
|
||||
|
||||
private const val TAG = "BiometricAuthenticator"
|
||||
private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
|
||||
private const val TAG = "BiometricAuthenticator"
|
||||
private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
|
||||
|
||||
sealed class Result {
|
||||
data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
|
||||
data class Failure(val code: Int?, val message: CharSequence) : Result()
|
||||
object HardwareUnavailableOrDisabled : Result()
|
||||
object Cancelled : Result()
|
||||
}
|
||||
sealed class Result {
|
||||
data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
|
||||
data class Failure(val code: Int?, val message: CharSequence) : Result()
|
||||
object HardwareUnavailableOrDisabled : Result()
|
||||
object Cancelled : Result()
|
||||
}
|
||||
|
||||
fun canAuthenticate(activity: FragmentActivity): Boolean {
|
||||
return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
fun canAuthenticate(activity: FragmentActivity): Boolean {
|
||||
return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
fun authenticate(
|
||||
activity: FragmentActivity,
|
||||
@StringRes dialogTitleRes: Int = R.string.biometric_prompt_title,
|
||||
callback: (Result) -> Unit
|
||||
) {
|
||||
val authCallback = object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" }
|
||||
callback(when (errorCode) {
|
||||
BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED,
|
||||
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
|
||||
Result.Cancelled
|
||||
}
|
||||
BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE,
|
||||
BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
|
||||
Result.HardwareUnavailableOrDisabled
|
||||
}
|
||||
else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString))
|
||||
})
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error)))
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
callback(Result.Success(result.cryptoObject))
|
||||
fun authenticate(
|
||||
activity: FragmentActivity,
|
||||
@StringRes dialogTitleRes: Int = R.string.biometric_prompt_title,
|
||||
callback: (Result) -> Unit
|
||||
) {
|
||||
val authCallback =
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" }
|
||||
callback(
|
||||
when (errorCode) {
|
||||
BiometricPrompt.ERROR_CANCELED,
|
||||
BiometricPrompt.ERROR_USER_CANCELED,
|
||||
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
|
||||
Result.Cancelled
|
||||
}
|
||||
BiometricPrompt.ERROR_HW_NOT_PRESENT,
|
||||
BiometricPrompt.ERROR_HW_UNAVAILABLE,
|
||||
BiometricPrompt.ERROR_NO_BIOMETRICS,
|
||||
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
|
||||
Result.HardwareUnavailableOrDisabled
|
||||
}
|
||||
else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString))
|
||||
}
|
||||
)
|
||||
}
|
||||
val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true
|
||||
if (canAuthenticate(activity) || deviceHasKeyguard) {
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(activity.getString(dialogTitleRes))
|
||||
.setAllowedAuthenticators(validAuthenticators)
|
||||
.build()
|
||||
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback).authenticate(promptInfo)
|
||||
} else {
|
||||
callback(Result.HardwareUnavailableOrDisabled)
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
callback(Result.Failure(null, activity.getString(R.string.biometric_auth_error)))
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
callback(Result.Success(result.cryptoObject))
|
||||
}
|
||||
}
|
||||
val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true
|
||||
if (canAuthenticate(activity) || deviceHasKeyguard) {
|
||||
val promptInfo =
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(activity.getString(dialogTitleRes))
|
||||
.setAllowedAuthenticators(validAuthenticators)
|
||||
.build()
|
||||
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback)
|
||||
.authenticate(promptInfo)
|
||||
} else {
|
||||
callback(Result.HardwareUnavailableOrDisabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,163 +27,166 @@ import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
|
|||
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Implements [AutofillResponseBuilder]'s methods for API 30 and above
|
||||
*/
|
||||
/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
class Api30AutofillResponseBuilder(form: FillableForm) {
|
||||
|
||||
private val formOrigin = form.formOrigin
|
||||
private val scenario = form.scenario
|
||||
private val ignoredIds = form.ignoredIds
|
||||
private val saveFlags = form.saveFlags
|
||||
private val clientState = form.toClientState()
|
||||
private val formOrigin = form.formOrigin
|
||||
private val scenario = form.scenario
|
||||
private val ignoredIds = form.ignoredIds
|
||||
private val saveFlags = form.saveFlags
|
||||
private val clientState = form.toClientState()
|
||||
|
||||
// We do not offer save when the only relevant field is a username field or there is no field.
|
||||
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
|
||||
private val canBeSaved = saveFlags != null && scenarioSupportsSave
|
||||
// We do not offer save when the only relevant field is a username field or there is no field.
|
||||
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
|
||||
private val canBeSaved = saveFlags != null && scenarioSupportsSave
|
||||
|
||||
private fun makeIntentDataset(
|
||||
context: Context,
|
||||
action: AutofillAction,
|
||||
intentSender: IntentSender,
|
||||
metadata: DatasetMetadata,
|
||||
imeSpec: InlinePresentationSpec?,
|
||||
): Dataset {
|
||||
return Dataset.Builder(makeRemoteView(context, metadata)).run {
|
||||
fillWith(scenario, action, credentials = null)
|
||||
setAuthentication(intentSender)
|
||||
if (imeSpec != null) {
|
||||
val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata)
|
||||
if (inlinePresentation != null) {
|
||||
setInlinePresentation(inlinePresentation)
|
||||
}
|
||||
}
|
||||
build()
|
||||
private fun makeIntentDataset(
|
||||
context: Context,
|
||||
action: AutofillAction,
|
||||
intentSender: IntentSender,
|
||||
metadata: DatasetMetadata,
|
||||
imeSpec: InlinePresentationSpec?,
|
||||
): Dataset {
|
||||
return Dataset.Builder(makeRemoteView(context, metadata)).run {
|
||||
fillWith(scenario, action, credentials = null)
|
||||
setAuthentication(intentSender)
|
||||
if (imeSpec != null) {
|
||||
val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata)
|
||||
if (inlinePresentation != null) {
|
||||
setInlinePresentation(inlinePresentation)
|
||||
}
|
||||
}
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
||||
val metadata = makeFillMatchMetadata(context, file)
|
||||
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
|
||||
private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
||||
val metadata = makeFillMatchMetadata(context, file)
|
||||
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
|
||||
}
|
||||
|
||||
private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
|
||||
val metadata = makeSearchAndFillMetadata(context)
|
||||
val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
|
||||
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec)
|
||||
}
|
||||
|
||||
private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
|
||||
val metadata = makeGenerateAndFillMetadata(context)
|
||||
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
|
||||
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec)
|
||||
}
|
||||
|
||||
private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
|
||||
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
|
||||
val metadata = makeFillOtpFromSmsMetadata(context)
|
||||
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
|
||||
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec)
|
||||
}
|
||||
|
||||
private fun makePublisherChangedDataset(
|
||||
context: Context,
|
||||
publisherChangedException: AutofillPublisherChangedException,
|
||||
imeSpec: InlinePresentationSpec?
|
||||
): Dataset {
|
||||
val metadata = makeWarningMetadata(context)
|
||||
// If the user decides to trust the new publisher, they can choose reset the list of
|
||||
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
|
||||
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
|
||||
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
|
||||
val fillResponseAfterReset = makeFillResponse(context, null, emptyList())
|
||||
val intentSender =
|
||||
AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
|
||||
context,
|
||||
publisherChangedException,
|
||||
fillResponseAfterReset
|
||||
)
|
||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
|
||||
}
|
||||
|
||||
private fun makePublisherChangedResponse(
|
||||
context: Context,
|
||||
inlineSuggestionsRequest: InlineSuggestionsRequest?,
|
||||
publisherChangedException: AutofillPublisherChangedException
|
||||
): FillResponse {
|
||||
val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull()
|
||||
return FillResponse.Builder().run {
|
||||
addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec))
|
||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
|
||||
val metadata = makeSearchAndFillMetadata(context)
|
||||
val intentSender =
|
||||
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
|
||||
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec)
|
||||
}
|
||||
|
||||
private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
|
||||
val metadata = makeGenerateAndFillMetadata(context)
|
||||
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
|
||||
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec)
|
||||
}
|
||||
|
||||
|
||||
private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
|
||||
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
|
||||
val metadata = makeFillOtpFromSmsMetadata(context)
|
||||
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
|
||||
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec)
|
||||
}
|
||||
|
||||
private fun makePublisherChangedDataset(
|
||||
context: Context,
|
||||
publisherChangedException: AutofillPublisherChangedException,
|
||||
imeSpec: InlinePresentationSpec?
|
||||
): Dataset {
|
||||
val metadata = makeWarningMetadata(context)
|
||||
// If the user decides to trust the new publisher, they can choose reset the list of
|
||||
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
|
||||
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
|
||||
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
|
||||
val fillResponseAfterReset = makeFillResponse(context, null, emptyList())
|
||||
val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
|
||||
context, publisherChangedException, fillResponseAfterReset
|
||||
)
|
||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
|
||||
}
|
||||
|
||||
private fun makePublisherChangedResponse(
|
||||
context: Context,
|
||||
inlineSuggestionsRequest: InlineSuggestionsRequest?,
|
||||
publisherChangedException: AutofillPublisherChangedException
|
||||
): FillResponse {
|
||||
val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull()
|
||||
return FillResponse.Builder().run {
|
||||
addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec))
|
||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||
build()
|
||||
private fun makeFillResponse(
|
||||
context: Context,
|
||||
inlineSuggestionsRequest: InlineSuggestionsRequest?,
|
||||
matchedFiles: List<File>
|
||||
): FillResponse? {
|
||||
var datasetCount = 0
|
||||
val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
|
||||
return FillResponse.Builder().run {
|
||||
for (file in matchedFiles) {
|
||||
makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
}
|
||||
makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
if (datasetCount == 0) return null
|
||||
setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
|
||||
makeSaveInfo()?.let { setSaveInfo(it) }
|
||||
setClientState(clientState)
|
||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List<File>): FillResponse? {
|
||||
var datasetCount = 0
|
||||
val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
|
||||
return FillResponse.Builder().run {
|
||||
for (file in matchedFiles) {
|
||||
makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
}
|
||||
makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
if (datasetCount == 0) return null
|
||||
setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
|
||||
makeSaveInfo()?.let { setSaveInfo(it) }
|
||||
setClientState(clientState)
|
||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||
build()
|
||||
}
|
||||
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
|
||||
// See:
|
||||
// https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
|
||||
private fun makeSaveInfo(): SaveInfo? {
|
||||
if (!canBeSaved) return null
|
||||
check(saveFlags != null)
|
||||
val idsToSave = scenario.fieldsToSave.toTypedArray()
|
||||
if (idsToSave.isEmpty()) return null
|
||||
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
|
||||
if (scenario.hasUsername) {
|
||||
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
|
||||
}
|
||||
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
|
||||
setFlags(saveFlags)
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
|
||||
// See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
|
||||
private fun makeSaveInfo(): SaveInfo? {
|
||||
if (!canBeSaved) return null
|
||||
check(saveFlags != null)
|
||||
val idsToSave = scenario.fieldsToSave.toTypedArray()
|
||||
if (idsToSave.isEmpty()) return null
|
||||
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
|
||||
if (scenario.hasUsername) {
|
||||
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
|
||||
/** Creates and returns a suitable [FillResponse] to the Autofill framework. */
|
||||
fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) {
|
||||
AutofillMatcher.getMatchesFor(context, formOrigin)
|
||||
.fold(
|
||||
success = { matchedFiles ->
|
||||
callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
|
||||
},
|
||||
failure = { e ->
|
||||
e(e)
|
||||
callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e))
|
||||
}
|
||||
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
|
||||
setFlags(saveFlags)
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a suitable [FillResponse] to the Autofill framework.
|
||||
*/
|
||||
fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) {
|
||||
AutofillMatcher.getMatchesFor(context, formOrigin).fold(
|
||||
success = { matchedFiles ->
|
||||
callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
|
||||
},
|
||||
failure = { e ->
|
||||
e(e)
|
||||
callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e))
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,173 +21,165 @@ import java.io.File
|
|||
|
||||
private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches"
|
||||
private val Context.autofillAppMatches
|
||||
get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
|
||||
get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
|
||||
|
||||
private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches"
|
||||
private val Context.autofillWebMatches
|
||||
get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
|
||||
get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
|
||||
|
||||
private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences {
|
||||
return when (formOrigin) {
|
||||
is FormOrigin.App -> autofillAppMatches
|
||||
is FormOrigin.Web -> autofillWebMatches
|
||||
}
|
||||
return when (formOrigin) {
|
||||
is FormOrigin.App -> autofillAppMatches
|
||||
is FormOrigin.Web -> autofillWebMatches
|
||||
}
|
||||
}
|
||||
|
||||
class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
|
||||
Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
|
||||
Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
|
||||
|
||||
init {
|
||||
require(formOrigin is FormOrigin.App)
|
||||
}
|
||||
init {
|
||||
require(formOrigin is FormOrigin.App)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages "matches", i.e., associations between apps or websites and Password Store entries.
|
||||
*/
|
||||
/** Manages "matches", i.e., associations between apps or websites and Password Store entries. */
|
||||
class AutofillMatcher {
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
private const val MAX_NUM_MATCHES = 10
|
||||
private const val MAX_NUM_MATCHES = 10
|
||||
|
||||
private const val PREFERENCE_PREFIX_TOKEN = "token;"
|
||||
private fun tokenKey(formOrigin: FormOrigin.App) =
|
||||
"$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
|
||||
private const val PREFERENCE_PREFIX_TOKEN = "token;"
|
||||
private fun tokenKey(formOrigin: FormOrigin.App) = "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
|
||||
|
||||
private const val PREFERENCE_PREFIX_MATCHES = "matches;"
|
||||
private fun matchesKey(formOrigin: FormOrigin) =
|
||||
"$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
|
||||
private const val PREFERENCE_PREFIX_MATCHES = "matches;"
|
||||
private fun matchesKey(formOrigin: FormOrigin) = "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
|
||||
|
||||
private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
|
||||
return when (formOrigin) {
|
||||
is FormOrigin.Web -> false
|
||||
is FormOrigin.App -> {
|
||||
val packageName = formOrigin.identifier
|
||||
val certificatesHash = computeCertificatesHash(context, packageName)
|
||||
val storedCertificatesHash =
|
||||
context.autofillAppMatches.getString(tokenKey(formOrigin), null)
|
||||
?: return false
|
||||
val hashHasChanged = certificatesHash != storedCertificatesHash
|
||||
if (hashHasChanged) {
|
||||
e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
|
||||
if (formOrigin is FormOrigin.App) {
|
||||
val packageName = formOrigin.identifier
|
||||
val certificatesHash = computeCertificatesHash(context, packageName)
|
||||
context.autofillAppMatches.edit {
|
||||
putString(tokenKey(formOrigin), certificatesHash)
|
||||
}
|
||||
}
|
||||
// We don't need to store a hash for FormOrigin.Web since it can only originate from
|
||||
// browsers we trust to verify the origin.
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Password Store entries that have already been associated with [formOrigin] by the
|
||||
* user.
|
||||
*
|
||||
* If [formOrigin] represents an app and that app's certificates have changed since the
|
||||
* first time the user associated an entry with it, an [AutofillPublisherChangedException]
|
||||
* will be thrown.
|
||||
*/
|
||||
fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> {
|
||||
if (hasFormOriginHashChanged(context, formOrigin)) {
|
||||
return Err(AutofillPublisherChangedException(formOrigin))
|
||||
}
|
||||
val matchPreferences = context.matchPreferences(formOrigin)
|
||||
val matchedFiles =
|
||||
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
|
||||
return Ok(matchedFiles.filter { it.exists() }.also { validFiles ->
|
||||
matchPreferences.edit {
|
||||
putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
|
||||
context.matchPreferences(formOrigin).edit {
|
||||
remove(matchesKey(formOrigin))
|
||||
if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates the store entry [file] with [formOrigin], such that future Autofill responses
|
||||
* to requests from this app or website offer this entry as a dataset.
|
||||
*
|
||||
* The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of
|
||||
* Android may crash when too many datasets are offered.
|
||||
*/
|
||||
fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
|
||||
if (!file.exists()) return
|
||||
if (hasFormOriginHashChanged(context, formOrigin)) {
|
||||
// This should never happen since we already verified the publisher in
|
||||
// getMatchesFor.
|
||||
e { "App publisher changed between getMatchesFor and addMatchFor" }
|
||||
throw AutofillPublisherChangedException(formOrigin)
|
||||
}
|
||||
val matchPreferences = context.matchPreferences(formOrigin)
|
||||
val matchedFiles =
|
||||
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
|
||||
val newFiles = setOf(file.absoluteFile).union(matchedFiles)
|
||||
if (newFiles.size > MAX_NUM_MATCHES) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
return
|
||||
}
|
||||
matchPreferences.edit {
|
||||
putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet())
|
||||
}
|
||||
storeFormOriginHash(context, formOrigin)
|
||||
d { "Stored match for $formOrigin" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes through all existing matches and updates their associated entries by using
|
||||
* [moveFromTo] as a lookup table and deleting the matches for files in [delete].
|
||||
*/
|
||||
fun updateMatches(context: Context, moveFromTo: Map<File, File> = emptyMap(), delete: Collection<File> = emptyList()) {
|
||||
val deletePathList = delete.map { it.absolutePath }
|
||||
val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }
|
||||
.mapKeys { it.key.absolutePath }
|
||||
for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
|
||||
for ((key, value) in prefs.all) {
|
||||
if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
|
||||
// We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
|
||||
// created with `putStringSet`.
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val oldMatches = value as? Set<String>
|
||||
if (oldMatches == null) {
|
||||
w { "Failed to read matches for $key" }
|
||||
continue
|
||||
}
|
||||
// Delete all matches for file locations that are going to be overwritten, then
|
||||
// transfer matches over to the files at their new locations.
|
||||
val newMatches =
|
||||
oldMatches.asSequence()
|
||||
.minus(deletePathList)
|
||||
.minus(oldNewPathMap.values)
|
||||
.map { match ->
|
||||
val newPath = oldNewPathMap[match] ?: return@map match
|
||||
d { "Updating match for $key: $match --> $newPath" }
|
||||
newPath
|
||||
}.toSet()
|
||||
if (newMatches != oldMatches)
|
||||
prefs.edit { putStringSet(key, newMatches) }
|
||||
}
|
||||
}
|
||||
private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
|
||||
return when (formOrigin) {
|
||||
is FormOrigin.Web -> false
|
||||
is FormOrigin.App -> {
|
||||
val packageName = formOrigin.identifier
|
||||
val certificatesHash = computeCertificatesHash(context, packageName)
|
||||
val storedCertificatesHash = context.autofillAppMatches.getString(tokenKey(formOrigin), null) ?: return false
|
||||
val hashHasChanged = certificatesHash != storedCertificatesHash
|
||||
if (hashHasChanged) {
|
||||
e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
|
||||
if (formOrigin is FormOrigin.App) {
|
||||
val packageName = formOrigin.identifier
|
||||
val certificatesHash = computeCertificatesHash(context, packageName)
|
||||
context.autofillAppMatches.edit { putString(tokenKey(formOrigin), certificatesHash) }
|
||||
}
|
||||
// We don't need to store a hash for FormOrigin.Web since it can only originate from
|
||||
// browsers we trust to verify the origin.
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Password Store entries that have already been associated with [formOrigin] by the
|
||||
* user.
|
||||
*
|
||||
* If [formOrigin] represents an app and that app's certificates have changed since the first
|
||||
* time the user associated an entry with it, an [AutofillPublisherChangedException] will be
|
||||
* thrown.
|
||||
*/
|
||||
fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> {
|
||||
if (hasFormOriginHashChanged(context, formOrigin)) {
|
||||
return Err(AutofillPublisherChangedException(formOrigin))
|
||||
}
|
||||
val matchPreferences = context.matchPreferences(formOrigin)
|
||||
val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
|
||||
return Ok(
|
||||
matchedFiles.filter { it.exists() }.also { validFiles ->
|
||||
matchPreferences.edit { putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
|
||||
context.matchPreferences(formOrigin).edit {
|
||||
remove(matchesKey(formOrigin))
|
||||
if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates the store entry [file] with [formOrigin], such that future Autofill responses to
|
||||
* requests from this app or website offer this entry as a dataset.
|
||||
*
|
||||
* The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of Android
|
||||
* may crash when too many datasets are offered.
|
||||
*/
|
||||
fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
|
||||
if (!file.exists()) return
|
||||
if (hasFormOriginHashChanged(context, formOrigin)) {
|
||||
// This should never happen since we already verified the publisher in
|
||||
// getMatchesFor.
|
||||
e { "App publisher changed between getMatchesFor and addMatchFor" }
|
||||
throw AutofillPublisherChangedException(formOrigin)
|
||||
}
|
||||
val matchPreferences = context.matchPreferences(formOrigin)
|
||||
val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
|
||||
val newFiles = setOf(file.absoluteFile).union(matchedFiles)
|
||||
if (newFiles.size > MAX_NUM_MATCHES) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
matchPreferences.edit { putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) }
|
||||
storeFormOriginHash(context, formOrigin)
|
||||
d { "Stored match for $formOrigin" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Goes through all existing matches and updates their associated entries by using [moveFromTo]
|
||||
* as a lookup table and deleting the matches for files in [delete].
|
||||
*/
|
||||
fun updateMatches(
|
||||
context: Context,
|
||||
moveFromTo: Map<File, File> = emptyMap(),
|
||||
delete: Collection<File> = emptyList()
|
||||
) {
|
||||
val deletePathList = delete.map { it.absolutePath }
|
||||
val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }.mapKeys { it.key.absolutePath }
|
||||
for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
|
||||
for ((key, value) in prefs.all) {
|
||||
if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
|
||||
// We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
|
||||
// created with `putStringSet`.
|
||||
@Suppress("UNCHECKED_CAST") val oldMatches = value as? Set<String>
|
||||
if (oldMatches == null) {
|
||||
w { "Failed to read matches for $key" }
|
||||
continue
|
||||
}
|
||||
// Delete all matches for file locations that are going to be overwritten, then
|
||||
// transfer matches over to the files at their new locations.
|
||||
val newMatches =
|
||||
oldMatches
|
||||
.asSequence()
|
||||
.minus(deletePathList)
|
||||
.minus(oldNewPathMap.values)
|
||||
.map { match ->
|
||||
val newPath = oldNewPathMap[match] ?: return@map match
|
||||
d { "Updating match for $key: $match --> $newPath" }
|
||||
newPath
|
||||
}
|
||||
.toSet()
|
||||
if (newMatches != oldMatches) prefs.edit { putStringSet(key, newMatches) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,125 +17,128 @@ import java.io.File
|
|||
import java.nio.file.Paths
|
||||
|
||||
enum class DirectoryStructure(val value: String) {
|
||||
EncryptedUsername("encrypted_username"),
|
||||
FileBased("file"),
|
||||
DirectoryBased("directory");
|
||||
EncryptedUsername("encrypted_username"),
|
||||
FileBased("file"),
|
||||
DirectoryBased("directory");
|
||||
|
||||
/**
|
||||
* Returns the username associated to [file], following the convention of the current
|
||||
* [DirectoryStructure].
|
||||
*
|
||||
* Examples:
|
||||
* - * --> null (EncryptedUsername)
|
||||
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
|
||||
* - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
|
||||
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
|
||||
*/
|
||||
fun getUsernameFor(file: File): String? = when (this) {
|
||||
EncryptedUsername -> null
|
||||
FileBased -> file.nameWithoutExtension
|
||||
DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
|
||||
/**
|
||||
* Returns the username associated to [file], following the convention of the current
|
||||
* [DirectoryStructure].
|
||||
*
|
||||
* Examples:
|
||||
* - * --> null (EncryptedUsername)
|
||||
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
|
||||
* - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
|
||||
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
|
||||
*/
|
||||
fun getUsernameFor(file: File): String? =
|
||||
when (this) {
|
||||
EncryptedUsername -> null
|
||||
FileBased -> file.nameWithoutExtension
|
||||
DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the origin identifier associated to [file], following the convention of the current
|
||||
* [DirectoryStructure].
|
||||
*
|
||||
* At least one of [DirectoryStructure.getIdentifierFor] and
|
||||
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
|
||||
*
|
||||
* Examples:
|
||||
* - work/example.org.gpg --> example.org (EncryptedUsername)
|
||||
* - work/example.org/john@doe.org.gpg --> example.org (FileBased)
|
||||
* - example.org.gpg --> example.org (FileBased, fallback)
|
||||
* - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
|
||||
* - Temporary PIN.gpg --> null (DirectoryBased)
|
||||
*/
|
||||
fun getIdentifierFor(file: File): String? = when (this) {
|
||||
EncryptedUsername -> file.nameWithoutExtension
|
||||
FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
|
||||
DirectoryBased -> file.parentFile?.parent
|
||||
/**
|
||||
* Returns the origin identifier associated to [file], following the convention of the current
|
||||
* [DirectoryStructure].
|
||||
*
|
||||
* At least one of [DirectoryStructure.getIdentifierFor] and
|
||||
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
|
||||
*
|
||||
* Examples:
|
||||
* - work/example.org.gpg --> example.org (EncryptedUsername)
|
||||
* - work/example.org/john@doe.org.gpg --> example.org (FileBased)
|
||||
* - example.org.gpg --> example.org (FileBased, fallback)
|
||||
* - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
|
||||
* - Temporary PIN.gpg --> null (DirectoryBased)
|
||||
*/
|
||||
fun getIdentifierFor(file: File): String? =
|
||||
when (this) {
|
||||
EncryptedUsername -> file.nameWithoutExtension
|
||||
FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
|
||||
DirectoryBased -> file.parentFile?.parent
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path components of [file] until right before the component that contains the
|
||||
* origin identifier according to the current [DirectoryStructure].
|
||||
*
|
||||
* Examples:
|
||||
* - work/example.org.gpg --> work (EncryptedUsername)
|
||||
* - work/example.org/john@doe.org.gpg --> work (FileBased)
|
||||
* - example.org/john@doe.org.gpg --> null (FileBased)
|
||||
* - john@doe.org.gpg --> null (FileBased)
|
||||
* - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
|
||||
* - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
|
||||
*/
|
||||
fun getPathToIdentifierFor(file: File): String? = when (this) {
|
||||
EncryptedUsername -> file.parent
|
||||
FileBased -> file.parentFile?.parent
|
||||
DirectoryBased -> file.parentFile?.parentFile?.parent
|
||||
/**
|
||||
* Returns the path components of [file] until right before the component that contains the origin
|
||||
* identifier according to the current [DirectoryStructure].
|
||||
*
|
||||
* Examples:
|
||||
* - work/example.org.gpg --> work (EncryptedUsername)
|
||||
* - work/example.org/john@doe.org.gpg --> work (FileBased)
|
||||
* - example.org/john@doe.org.gpg --> null (FileBased)
|
||||
* - john@doe.org.gpg --> null (FileBased)
|
||||
* - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
|
||||
* - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
|
||||
*/
|
||||
fun getPathToIdentifierFor(file: File): String? =
|
||||
when (this) {
|
||||
EncryptedUsername -> file.parent
|
||||
FileBased -> file.parentFile?.parent
|
||||
DirectoryBased -> file.parentFile?.parentFile?.parent
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path component of [file] following the origin identifier according to the current
|
||||
* [DirectoryStructure] (without file extension).
|
||||
*
|
||||
* At least one of [DirectoryStructure.getIdentifierFor] and
|
||||
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
|
||||
*
|
||||
* Examples:
|
||||
* - * --> null (EncryptedUsername)
|
||||
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
|
||||
* - example.org.gpg --> null (FileBased, fallback)
|
||||
* - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
|
||||
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
|
||||
*/
|
||||
fun getAccountPartFor(file: File): String? = when (this) {
|
||||
EncryptedUsername -> null
|
||||
FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
|
||||
DirectoryBased -> file.parentFile?.let { parentFile ->
|
||||
"${parentFile.name}/${file.nameWithoutExtension}"
|
||||
} ?: file.nameWithoutExtension
|
||||
/**
|
||||
* Returns the path component of [file] following the origin identifier according to the current
|
||||
* [DirectoryStructure](without file extension).
|
||||
*
|
||||
* At least one of [DirectoryStructure.getIdentifierFor] and
|
||||
* [DirectoryStructure.getAccountPartFor] will always return a non-null result.
|
||||
*
|
||||
* Examples:
|
||||
* - * --> null (EncryptedUsername)
|
||||
* - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
|
||||
* - example.org.gpg --> null (FileBased, fallback)
|
||||
* - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
|
||||
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
|
||||
*/
|
||||
fun getAccountPartFor(file: File): String? =
|
||||
when (this) {
|
||||
EncryptedUsername -> null
|
||||
FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
|
||||
DirectoryBased -> file.parentFile?.let { parentFile -> "${parentFile.name}/${file.nameWithoutExtension}" }
|
||||
?: file.nameWithoutExtension
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) {
|
||||
EncryptedUsername -> "/"
|
||||
FileBased -> sanitizedIdentifier
|
||||
DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
fun getSaveFolderName(sanitizedIdentifier: String, username: String?) =
|
||||
when (this) {
|
||||
EncryptedUsername -> "/"
|
||||
FileBased -> sanitizedIdentifier
|
||||
DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
|
||||
}
|
||||
|
||||
fun getSaveFileName(username: String?, identifier: String) = when (this) {
|
||||
EncryptedUsername -> identifier
|
||||
FileBased -> username
|
||||
DirectoryBased -> "password"
|
||||
fun getSaveFileName(username: String?, identifier: String) =
|
||||
when (this) {
|
||||
EncryptedUsername -> identifier
|
||||
FileBased -> username
|
||||
DirectoryBased -> "password"
|
||||
}
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
val DEFAULT = FileBased
|
||||
val DEFAULT = FileBased
|
||||
|
||||
private val reverseMap = values().associateBy { it.value }
|
||||
fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
|
||||
}
|
||||
private val reverseMap = values().associateBy { it.value }
|
||||
fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
object AutofillPreferences {
|
||||
|
||||
fun directoryStructure(context: Context): DirectoryStructure {
|
||||
val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
|
||||
return DirectoryStructure.fromValue(value)
|
||||
}
|
||||
fun directoryStructure(context: Context): DirectoryStructure {
|
||||
val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
|
||||
return DirectoryStructure.fromValue(value)
|
||||
}
|
||||
|
||||
fun credentialsFromStoreEntry(
|
||||
context: Context,
|
||||
file: File,
|
||||
entry: PasswordEntry,
|
||||
directoryStructure: DirectoryStructure
|
||||
): Credentials {
|
||||
// Always give priority to a username stored in the encrypted extras
|
||||
val username = entry.username
|
||||
?: directoryStructure.getUsernameFor(file)
|
||||
?: context.getDefaultUsername()
|
||||
return Credentials(username, entry.password, entry.calculateTotpCode())
|
||||
}
|
||||
fun credentialsFromStoreEntry(
|
||||
context: Context,
|
||||
file: File,
|
||||
entry: PasswordEntry,
|
||||
directoryStructure: DirectoryStructure
|
||||
): Credentials {
|
||||
// Always give priority to a username stored in the encrypted extras
|
||||
val username = entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
|
||||
return Credentials(username, entry.password, entry.calculateTotpCode())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -30,176 +30,178 @@ import java.io.File
|
|||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
class AutofillResponseBuilder(form: FillableForm) {
|
||||
|
||||
private val formOrigin = form.formOrigin
|
||||
private val scenario = form.scenario
|
||||
private val ignoredIds = form.ignoredIds
|
||||
private val saveFlags = form.saveFlags
|
||||
private val clientState = form.toClientState()
|
||||
private val formOrigin = form.formOrigin
|
||||
private val scenario = form.scenario
|
||||
private val ignoredIds = form.ignoredIds
|
||||
private val saveFlags = form.saveFlags
|
||||
private val clientState = form.toClientState()
|
||||
|
||||
// We do not offer save when the only relevant field is a username field or there is no field.
|
||||
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
|
||||
private val canBeSaved = saveFlags != null && scenarioSupportsSave
|
||||
// We do not offer save when the only relevant field is a username field or there is no field.
|
||||
private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
|
||||
private val canBeSaved = saveFlags != null && scenarioSupportsSave
|
||||
|
||||
private fun makeIntentDataset(
|
||||
context: Context,
|
||||
action: AutofillAction,
|
||||
intentSender: IntentSender,
|
||||
metadata: DatasetMetadata,
|
||||
): Dataset {
|
||||
return Dataset.Builder(makeRemoteView(context, metadata)).run {
|
||||
fillWith(scenario, action, credentials = null)
|
||||
setAuthentication(intentSender)
|
||||
build()
|
||||
private fun makeIntentDataset(
|
||||
context: Context,
|
||||
action: AutofillAction,
|
||||
intentSender: IntentSender,
|
||||
metadata: DatasetMetadata,
|
||||
): Dataset {
|
||||
return Dataset.Builder(makeRemoteView(context, metadata)).run {
|
||||
fillWith(scenario, action, credentials = null)
|
||||
setAuthentication(intentSender)
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeMatchDataset(context: Context, file: File): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
||||
val metadata = makeFillMatchMetadata(context, file)
|
||||
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
|
||||
}
|
||||
|
||||
private fun makeSearchDataset(context: Context): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
|
||||
val metadata = makeSearchAndFillMetadata(context)
|
||||
val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
|
||||
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
|
||||
}
|
||||
|
||||
private fun makeGenerateDataset(context: Context): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
|
||||
val metadata = makeGenerateAndFillMetadata(context)
|
||||
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
|
||||
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata)
|
||||
}
|
||||
|
||||
private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
|
||||
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
|
||||
val metadata = makeFillOtpFromSmsMetadata(context)
|
||||
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
|
||||
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata)
|
||||
}
|
||||
|
||||
private fun makePublisherChangedDataset(
|
||||
context: Context,
|
||||
publisherChangedException: AutofillPublisherChangedException,
|
||||
): Dataset {
|
||||
val metadata = makeWarningMetadata(context)
|
||||
// If the user decides to trust the new publisher, they can choose reset the list of
|
||||
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
|
||||
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
|
||||
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
|
||||
val fillResponseAfterReset = makeFillResponse(context, emptyList())
|
||||
val intentSender =
|
||||
AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
|
||||
context,
|
||||
publisherChangedException,
|
||||
fillResponseAfterReset
|
||||
)
|
||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
|
||||
}
|
||||
|
||||
private fun makePublisherChangedResponse(
|
||||
context: Context,
|
||||
publisherChangedException: AutofillPublisherChangedException
|
||||
): FillResponse {
|
||||
return FillResponse.Builder().run {
|
||||
addDataset(makePublisherChangedDataset(context, publisherChangedException))
|
||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
|
||||
// See:
|
||||
// https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
|
||||
private fun makeSaveInfo(): SaveInfo? {
|
||||
if (!canBeSaved) return null
|
||||
check(saveFlags != null)
|
||||
val idsToSave = scenario.fieldsToSave.toTypedArray()
|
||||
if (idsToSave.isEmpty()) return null
|
||||
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
|
||||
if (scenario.hasUsername) {
|
||||
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
|
||||
}
|
||||
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
|
||||
setFlags(saveFlags)
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
|
||||
var datasetCount = 0
|
||||
return FillResponse.Builder().run {
|
||||
for (file in matchedFiles) {
|
||||
makeMatchDataset(context, file)?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeMatchDataset(context: Context, file: File): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
||||
val metadata = makeFillMatchMetadata(context, file)
|
||||
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
|
||||
}
|
||||
|
||||
|
||||
private fun makeSearchDataset(context: Context): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
|
||||
val metadata = makeSearchAndFillMetadata(context)
|
||||
val intentSender =
|
||||
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
|
||||
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
|
||||
}
|
||||
|
||||
private fun makeGenerateDataset(context: Context): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
|
||||
val metadata = makeGenerateAndFillMetadata(context)
|
||||
val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
|
||||
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata)
|
||||
}
|
||||
|
||||
private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
|
||||
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
|
||||
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
|
||||
val metadata = makeFillOtpFromSmsMetadata(context)
|
||||
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
|
||||
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata)
|
||||
}
|
||||
|
||||
private fun makePublisherChangedDataset(
|
||||
context: Context,
|
||||
publisherChangedException: AutofillPublisherChangedException,
|
||||
): Dataset {
|
||||
val metadata = makeWarningMetadata(context)
|
||||
// If the user decides to trust the new publisher, they can choose reset the list of
|
||||
// matches. In this case we need to immediately show a new `FillResponse` as if the app were
|
||||
// autofilled for the first time. This `FillResponse` needs to be returned as a result from
|
||||
// `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
|
||||
val fillResponseAfterReset = makeFillResponse(context, emptyList())
|
||||
val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
|
||||
context, publisherChangedException, fillResponseAfterReset
|
||||
}
|
||||
makeGenerateDataset(context)?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
makeFillOtpFromSmsDataset(context)?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
makeSearchDataset(context)?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
if (datasetCount == 0) return null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
setHeader(
|
||||
makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))
|
||||
)
|
||||
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
|
||||
}
|
||||
makeSaveInfo()?.let { setSaveInfo(it) }
|
||||
setClientState(clientState)
|
||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun makePublisherChangedResponse(
|
||||
context: Context,
|
||||
publisherChangedException: AutofillPublisherChangedException
|
||||
): FillResponse {
|
||||
return FillResponse.Builder().run {
|
||||
addDataset(makePublisherChangedDataset(context, publisherChangedException))
|
||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||
build()
|
||||
/** Creates and returns a suitable [FillResponse] to the Autofill framework. */
|
||||
fun fillCredentials(context: Context, callback: FillCallback) {
|
||||
AutofillMatcher.getMatchesFor(context, formOrigin)
|
||||
.fold(
|
||||
success = { matchedFiles -> callback.onSuccess(makeFillResponse(context, matchedFiles)) },
|
||||
failure = { e ->
|
||||
e(e)
|
||||
callback.onSuccess(makePublisherChangedResponse(context, e))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
|
||||
// See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
|
||||
private fun makeSaveInfo(): SaveInfo? {
|
||||
if (!canBeSaved) return null
|
||||
check(saveFlags != null)
|
||||
val idsToSave = scenario.fieldsToSave.toTypedArray()
|
||||
if (idsToSave.isEmpty()) return null
|
||||
var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
|
||||
if (scenario.hasUsername) {
|
||||
saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
|
||||
}
|
||||
return SaveInfo.Builder(saveDataTypes, idsToSave).run {
|
||||
setFlags(saveFlags)
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
|
||||
var datasetCount = 0
|
||||
return FillResponse.Builder().run {
|
||||
for (file in matchedFiles) {
|
||||
makeMatchDataset(context, file)?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
}
|
||||
makeGenerateDataset(context)?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
makeFillOtpFromSmsDataset(context)?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
makeSearchDataset(context)?.let {
|
||||
datasetCount++
|
||||
addDataset(it)
|
||||
}
|
||||
if (datasetCount == 0) return null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
|
||||
}
|
||||
makeSaveInfo()?.let { setSaveInfo(it) }
|
||||
setClientState(clientState)
|
||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns a suitable [FillResponse] to the Autofill framework.
|
||||
*/
|
||||
fun fillCredentials(context: Context, callback: FillCallback) {
|
||||
AutofillMatcher.getMatchesFor(context, formOrigin).fold(
|
||||
success = { matchedFiles ->
|
||||
callback.onSuccess(makeFillResponse(context, matchedFiles))
|
||||
},
|
||||
failure = { e ->
|
||||
e(e)
|
||||
callback.onSuccess(makePublisherChangedResponse(context, e))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun makeFillInDataset(
|
||||
context: Context,
|
||||
credentials: Credentials,
|
||||
clientState: Bundle,
|
||||
action: AutofillAction
|
||||
): Dataset {
|
||||
val scenario = AutofillScenario.fromClientState(clientState)
|
||||
// Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
|
||||
// though they are rarely shown.
|
||||
// FIXME: We should clone the original dataset here and add the credentials to be filled
|
||||
// in. Otherwise, the entry in the cached list of datasets will be overwritten by the
|
||||
// fill-in dataset without any visual representation. This causes it to be missing from
|
||||
// the Autofill suggestions shown after the user clears the filled out form fields.
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
Dataset.Builder()
|
||||
} else {
|
||||
Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))
|
||||
}
|
||||
return builder.run {
|
||||
if (scenario != null) fillWith(scenario, action, credentials)
|
||||
else e { "Failed to recover scenario from client state" }
|
||||
build()
|
||||
}
|
||||
companion object {
|
||||
|
||||
fun makeFillInDataset(
|
||||
context: Context,
|
||||
credentials: Credentials,
|
||||
clientState: Bundle,
|
||||
action: AutofillAction
|
||||
): Dataset {
|
||||
val scenario = AutofillScenario.fromClientState(clientState)
|
||||
// Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
|
||||
// though they are rarely shown.
|
||||
// FIXME: We should clone the original dataset here and add the credentials to be filled
|
||||
// in. Otherwise, the entry in the cached list of datasets will be overwritten by the
|
||||
// fill-in dataset without any visual representation. This causes it to be missing from
|
||||
// the Autofill suggestions shown after the user clears the filled out form fields.
|
||||
val builder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
Dataset.Builder()
|
||||
} else {
|
||||
Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))
|
||||
}
|
||||
return builder.run {
|
||||
if (scenario != null) fillWith(scenario, action, credentials)
|
||||
else e { "Failed to recover scenario from client state" }
|
||||
build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,88 +26,74 @@ import java.io.File
|
|||
data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int)
|
||||
|
||||
fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews {
|
||||
return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
|
||||
setTextViewText(R.id.title, metadata.title)
|
||||
if (metadata.subtitle != null) {
|
||||
setTextViewText(R.id.summary, metadata.subtitle)
|
||||
} else {
|
||||
setViewVisibility(R.id.summary, View.GONE)
|
||||
}
|
||||
if (metadata.iconRes != Resources.ID_NULL) {
|
||||
setImageViewResource(R.id.icon, metadata.iconRes)
|
||||
} else {
|
||||
setViewVisibility(R.id.icon, View.GONE)
|
||||
}
|
||||
return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
|
||||
setTextViewText(R.id.title, metadata.title)
|
||||
if (metadata.subtitle != null) {
|
||||
setTextViewText(R.id.summary, metadata.subtitle)
|
||||
} else {
|
||||
setViewVisibility(R.id.summary, View.GONE)
|
||||
}
|
||||
if (metadata.iconRes != Resources.ID_NULL) {
|
||||
setImageViewResource(R.id.icon, metadata.iconRes)
|
||||
} else {
|
||||
setViewVisibility(R.id.icon, View.GONE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
fun makeInlinePresentation(context: Context, imeSpec: InlinePresentationSpec, metadata: DatasetMetadata): InlinePresentation? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
|
||||
return null
|
||||
fun makeInlinePresentation(
|
||||
context: Context,
|
||||
imeSpec: InlinePresentationSpec,
|
||||
metadata: DatasetMetadata
|
||||
): InlinePresentation? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
|
||||
|
||||
if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style))
|
||||
return null
|
||||
if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null
|
||||
|
||||
val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0)
|
||||
val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run {
|
||||
setTitle(metadata.title)
|
||||
if (metadata.subtitle != null)
|
||||
setSubtitle(metadata.subtitle)
|
||||
setContentDescription(if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title)
|
||||
setStartIcon(Icon.createWithResource(context, metadata.iconRes))
|
||||
build().slice
|
||||
val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0)
|
||||
val slice =
|
||||
InlineSuggestionUi.newContentBuilder(launchIntent).run {
|
||||
setTitle(metadata.title)
|
||||
if (metadata.subtitle != null) setSubtitle(metadata.subtitle)
|
||||
setContentDescription(
|
||||
if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title
|
||||
)
|
||||
setStartIcon(Icon.createWithResource(context, metadata.iconRes))
|
||||
build().slice
|
||||
}
|
||||
|
||||
return InlinePresentation(slice, imeSpec, false)
|
||||
return InlinePresentation(slice, imeSpec, false)
|
||||
}
|
||||
|
||||
|
||||
fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata {
|
||||
val directoryStructure = AutofillPreferences.directoryStructure(context)
|
||||
val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
|
||||
val title = directoryStructure.getIdentifierFor(relativeFile)
|
||||
?: directoryStructure.getAccountPartFor(relativeFile)!!
|
||||
val subtitle = directoryStructure.getAccountPartFor(relativeFile)
|
||||
return DatasetMetadata(
|
||||
title,
|
||||
subtitle,
|
||||
R.drawable.ic_person_black_24dp
|
||||
)
|
||||
val directoryStructure = AutofillPreferences.directoryStructure(context)
|
||||
val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
|
||||
val title = directoryStructure.getIdentifierFor(relativeFile) ?: directoryStructure.getAccountPartFor(relativeFile)!!
|
||||
val subtitle = directoryStructure.getAccountPartFor(relativeFile)
|
||||
return DatasetMetadata(title, subtitle, R.drawable.ic_person_black_24dp)
|
||||
}
|
||||
|
||||
fun makeSearchAndFillMetadata(context: Context) = DatasetMetadata(
|
||||
context.getString(R.string.oreo_autofill_search_in_store),
|
||||
null,
|
||||
R.drawable.ic_search_black_24dp
|
||||
)
|
||||
fun makeSearchAndFillMetadata(context: Context) =
|
||||
DatasetMetadata(context.getString(R.string.oreo_autofill_search_in_store), null, R.drawable.ic_search_black_24dp)
|
||||
|
||||
fun makeGenerateAndFillMetadata(context: Context) = DatasetMetadata(
|
||||
fun makeGenerateAndFillMetadata(context: Context) =
|
||||
DatasetMetadata(
|
||||
context.getString(R.string.oreo_autofill_generate_password),
|
||||
null,
|
||||
R.drawable.ic_autofill_new_password
|
||||
)
|
||||
)
|
||||
|
||||
fun makeFillOtpFromSmsMetadata(context: Context) = DatasetMetadata(
|
||||
context.getString(R.string.oreo_autofill_fill_otp_from_sms),
|
||||
null,
|
||||
R.drawable.ic_autofill_sms
|
||||
)
|
||||
fun makeFillOtpFromSmsMetadata(context: Context) =
|
||||
DatasetMetadata(context.getString(R.string.oreo_autofill_fill_otp_from_sms), null, R.drawable.ic_autofill_sms)
|
||||
|
||||
fun makeEmptyMetadata() = DatasetMetadata(
|
||||
"PLACEHOLDER",
|
||||
"PLACEHOLDER",
|
||||
R.mipmap.ic_launcher
|
||||
)
|
||||
fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
|
||||
|
||||
fun makeWarningMetadata(context: Context) = DatasetMetadata(
|
||||
fun makeWarningMetadata(context: Context) =
|
||||
DatasetMetadata(
|
||||
context.getString(R.string.oreo_autofill_warning_publisher_dataset_title),
|
||||
context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary),
|
||||
R.drawable.ic_warning_red_24dp
|
||||
)
|
||||
)
|
||||
|
||||
fun makeHeaderMetadata(title: String) = DatasetMetadata(
|
||||
title,
|
||||
null,
|
||||
0
|
||||
)
|
||||
fun makeHeaderMetadata(title: String) = DatasetMetadata(title, null, 0)
|
||||
|
|
|
@ -8,36 +8,33 @@ package dev.msfjarvis.aps.util.crypto
|
|||
import me.msfjarvis.openpgpktx.util.OpenPgpUtils
|
||||
|
||||
sealed class GpgIdentifier {
|
||||
data class KeyId(val id: Long) : GpgIdentifier()
|
||||
data class UserId(val email: String) : GpgIdentifier()
|
||||
data class KeyId(val id: Long) : GpgIdentifier()
|
||||
data class UserId(val email: String) : GpgIdentifier()
|
||||
|
||||
companion object {
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
fun fromString(identifier: String): GpgIdentifier? {
|
||||
if (identifier.isEmpty()) return null
|
||||
// Match long key IDs:
|
||||
// FF22334455667788 or 0xFF22334455667788
|
||||
val maybeLongKeyId = identifier.removePrefix("0x").takeIf {
|
||||
it.matches("[a-fA-F0-9]{16}".toRegex())
|
||||
}
|
||||
if (maybeLongKeyId != null) {
|
||||
val keyId = maybeLongKeyId.toULong(16)
|
||||
return KeyId(keyId.toLong())
|
||||
}
|
||||
companion object {
|
||||
@OptIn(ExperimentalUnsignedTypes::class)
|
||||
fun fromString(identifier: String): GpgIdentifier? {
|
||||
if (identifier.isEmpty()) return null
|
||||
// Match long key IDs:
|
||||
// FF22334455667788 or 0xFF22334455667788
|
||||
val maybeLongKeyId = identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{16}".toRegex()) }
|
||||
if (maybeLongKeyId != null) {
|
||||
val keyId = maybeLongKeyId.toULong(16)
|
||||
return KeyId(keyId.toLong())
|
||||
}
|
||||
|
||||
// Match fingerprints:
|
||||
// FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
|
||||
val maybeFingerprint = identifier.removePrefix("0x").takeIf {
|
||||
it.matches("[a-fA-F0-9]{40}".toRegex())
|
||||
}
|
||||
if (maybeFingerprint != null) {
|
||||
// Truncating to the long key ID is not a security issue since OpenKeychain only accepts
|
||||
// non-ambiguous key IDs.
|
||||
val keyId = maybeFingerprint.takeLast(16).toULong(16)
|
||||
return KeyId(keyId.toLong())
|
||||
}
|
||||
// Match fingerprints:
|
||||
// FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
|
||||
val maybeFingerprint = identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{40}".toRegex()) }
|
||||
if (maybeFingerprint != null) {
|
||||
// Truncating to the long key ID is not a security issue since OpenKeychain only
|
||||
// accepts
|
||||
// non-ambiguous key IDs.
|
||||
val keyId = maybeFingerprint.takeLast(16).toULong(16)
|
||||
return KeyId(keyId.toLong())
|
||||
}
|
||||
|
||||
return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) }
|
||||
}
|
||||
return OpenPgpUtils.splitUserId(identifier).email?.let { UserId(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,146 +34,115 @@ import dev.msfjarvis.aps.data.repo.PasswordRepository
|
|||
import dev.msfjarvis.aps.util.git.operation.GitOperation
|
||||
|
||||
/**
|
||||
* Extension function for [AlertDialog] that requests focus for the
|
||||
* view whose id is [id]. Solution based on a StackOverflow
|
||||
* answer: https://stackoverflow.com/a/13056259/297261
|
||||
* Extension function for [AlertDialog] that requests focus for the view whose id is [id]. Solution
|
||||
* based on a StackOverflow answer: https://stackoverflow.com/a/13056259/297261
|
||||
*/
|
||||
fun <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) {
|
||||
setOnShowListener {
|
||||
findViewById<T>(id)?.apply {
|
||||
setOnFocusChangeListener { v, _ ->
|
||||
v.post {
|
||||
context.getSystemService<InputMethodManager>()
|
||||
?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT)
|
||||
}
|
||||
}
|
||||
requestFocus()
|
||||
}
|
||||
setOnShowListener {
|
||||
findViewById<T>(id)?.apply {
|
||||
setOnFocusChangeListener { v, _ ->
|
||||
v.post { context.getSystemService<InputMethodManager>()?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) }
|
||||
}
|
||||
requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an instance of [AutofillManager]. Only
|
||||
* available on Android Oreo and above
|
||||
*/
|
||||
/** Get an instance of [AutofillManager]. Only available on Android Oreo and above */
|
||||
val Context.autofillManager: AutofillManager?
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
get() = getSystemService()
|
||||
@RequiresApi(Build.VERSION_CODES.O) get() = getSystemService()
|
||||
|
||||
/**
|
||||
* Get an instance of [ClipboardManager]
|
||||
*/
|
||||
/** Get an instance of [ClipboardManager] */
|
||||
val Context.clipboard
|
||||
get() = getSystemService<ClipboardManager>()
|
||||
get() = getSystemService<ClipboardManager>()
|
||||
|
||||
/**
|
||||
* Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at
|
||||
* each call site
|
||||
*/
|
||||
/** Wrapper for [getEncryptedPrefs] to avoid open-coding the file name at each call site */
|
||||
fun Context.getEncryptedGitPrefs() = getEncryptedPrefs("git_operation")
|
||||
|
||||
/**
|
||||
* Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP
|
||||
* proxy.
|
||||
*/
|
||||
/** Wrapper for [getEncryptedPrefs] to get the encrypted preference set for the HTTP proxy. */
|
||||
fun Context.getEncryptedProxyPrefs() = getEncryptedPrefs("http_proxy")
|
||||
|
||||
/**
|
||||
* Get an instance of [EncryptedSharedPreferences] with the given [fileName]
|
||||
*/
|
||||
/** Get an instance of [EncryptedSharedPreferences] with the given [fileName] */
|
||||
private fun Context.getEncryptedPrefs(fileName: String): SharedPreferences {
|
||||
val masterKeyAlias = MasterKey.Builder(applicationContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
applicationContext,
|
||||
fileName,
|
||||
masterKeyAlias,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
val masterKeyAlias = MasterKey.Builder(applicationContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
applicationContext,
|
||||
fileName,
|
||||
masterKeyAlias,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an instance of [KeyguardManager]
|
||||
*/
|
||||
/** Get an instance of [KeyguardManager] */
|
||||
val Context.keyguardManager: KeyguardManager
|
||||
get() = getSystemService()!!
|
||||
get() = getSystemService()!!
|
||||
|
||||
/**
|
||||
* Get the default [SharedPreferences] instance
|
||||
*/
|
||||
/** Get the default [SharedPreferences] instance */
|
||||
val Context.sharedPrefs: SharedPreferences
|
||||
get() = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
get() = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||
|
||||
|
||||
/**
|
||||
* Resolve [attr] from the [Context]'s theme
|
||||
*/
|
||||
/** Resolve [attr] from the [Context]'s theme */
|
||||
fun Context.resolveAttribute(attr: Int): Int {
|
||||
val typedValue = TypedValue()
|
||||
this.theme.resolveAttribute(attr, typedValue, true)
|
||||
return typedValue.data
|
||||
val typedValue = TypedValue()
|
||||
this.theme.resolveAttribute(attr, typedValue, true)
|
||||
return typedValue.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit changes to the store from a [FragmentActivity] using
|
||||
* a custom implementation of [GitOperation]
|
||||
* Commit changes to the store from a [FragmentActivity] using a custom implementation of
|
||||
* [GitOperation]
|
||||
*/
|
||||
suspend fun FragmentActivity.commitChange(
|
||||
message: String,
|
||||
message: String,
|
||||
): Result<Unit, Throwable> {
|
||||
if (!PasswordRepository.isGitRepo()) {
|
||||
return Ok(Unit)
|
||||
}
|
||||
return object : GitOperation(this@commitChange) {
|
||||
override val commands = arrayOf(
|
||||
// Stage all files
|
||||
git.add().addFilepattern("."),
|
||||
// Populate the changed files count
|
||||
git.status(),
|
||||
// Commit everything! If anything changed, that is.
|
||||
git.commit().setAll(true).setMessage(message),
|
||||
if (!PasswordRepository.isGitRepo()) {
|
||||
return Ok(Unit)
|
||||
}
|
||||
return object : GitOperation(this@commitChange) {
|
||||
override val commands =
|
||||
arrayOf(
|
||||
// Stage all files
|
||||
git.add().addFilepattern("."),
|
||||
// Populate the changed files count
|
||||
git.status(),
|
||||
// Commit everything! If anything changed, that is.
|
||||
git.commit().setAll(true).setMessage(message),
|
||||
)
|
||||
|
||||
override fun preExecute(): Boolean {
|
||||
d { "Committing with message: '$message'" }
|
||||
return true
|
||||
}
|
||||
}.execute()
|
||||
override fun preExecute(): Boolean {
|
||||
d { "Committing with message: '$message'" }
|
||||
return true
|
||||
}
|
||||
}
|
||||
.execute()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if [permission] has been granted to the app.
|
||||
*/
|
||||
/** Check if [permission] has been granted to the app. */
|
||||
fun FragmentActivity.isPermissionGranted(permission: String): Boolean {
|
||||
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||
return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a [Snackbar] in a [FragmentActivity] and correctly
|
||||
* anchor it to a [com.google.android.material.floatingactionbutton.FloatingActionButton]
|
||||
* if one exists in the [view]
|
||||
* Show a [Snackbar] in a [FragmentActivity] and correctly anchor it to a
|
||||
* [com.google.android.material.floatingactionbutton.FloatingActionButton] if one exists in the
|
||||
* [view]
|
||||
*/
|
||||
fun FragmentActivity.snackbar(
|
||||
view: View = findViewById(android.R.id.content),
|
||||
message: String,
|
||||
length: Int = Snackbar.LENGTH_SHORT,
|
||||
view: View = findViewById(android.R.id.content),
|
||||
message: String,
|
||||
length: Int = Snackbar.LENGTH_SHORT,
|
||||
): Snackbar {
|
||||
val snackbar = Snackbar.make(view, message, length)
|
||||
snackbar.anchorView = findViewById(R.id.fab)
|
||||
snackbar.show()
|
||||
return snackbar
|
||||
val snackbar = Snackbar.make(view, message, length)
|
||||
snackbar.anchorView = findViewById(R.id.fab)
|
||||
snackbar.show()
|
||||
return snackbar
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplifies the common `getString(key, null) ?: defaultValue` case slightly
|
||||
*/
|
||||
/** Simplifies the common `getString(key, null) ?: defaultValue` case slightly */
|
||||
fun SharedPreferences.getString(key: String): String? = getString(key, null)
|
||||
|
||||
/**
|
||||
* Convert this [String] to its [Base64] representation
|
||||
*/
|
||||
/** Convert this [String] to its [Base64] representation */
|
||||
fun String.base64(): String {
|
||||
return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP)
|
||||
return Base64.encodeToString(encodeToByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
|
|
@ -12,53 +12,40 @@ import java.util.Date
|
|||
import org.eclipse.jgit.lib.ObjectId
|
||||
import org.eclipse.jgit.revwalk.RevCommit
|
||||
|
||||
/**
|
||||
* The default OpenPGP provider for the app
|
||||
*/
|
||||
/** The default OpenPGP provider for the app */
|
||||
const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
|
||||
|
||||
/**
|
||||
* Clears the given [flag] from the value of this [Int]
|
||||
*/
|
||||
/** Clears the given [flag] from the value of this [Int] */
|
||||
fun Int.clearFlag(flag: Int): Int {
|
||||
return this and flag.inv()
|
||||
return this and flag.inv()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this [Int] contains the given [flag]
|
||||
*/
|
||||
/** Checks if this [Int] contains the given [flag] */
|
||||
infix fun Int.hasFlag(flag: Int): Boolean {
|
||||
return this and flag == flag
|
||||
return this and flag == flag
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether this [File] is a directory that contains [other].
|
||||
*/
|
||||
/** Checks whether this [File] is a directory that contains [other]. */
|
||||
fun File.contains(other: File): Boolean {
|
||||
if (!isDirectory)
|
||||
return false
|
||||
if (!other.exists())
|
||||
return false
|
||||
val relativePath = runCatching {
|
||||
other.relativeTo(this)
|
||||
}.getOrElse {
|
||||
return false
|
||||
if (!isDirectory) return false
|
||||
if (!other.exists()) return false
|
||||
val relativePath =
|
||||
runCatching { other.relativeTo(this) }.getOrElse {
|
||||
return false
|
||||
}
|
||||
// Direct containment is equivalent to the relative path being equal to the filename.
|
||||
return relativePath.path == other.name
|
||||
// Direct containment is equivalent to the relative path being equal to the filename.
|
||||
return relativePath.path == other.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this [File] is in the password repository directory as given
|
||||
* by [PasswordRepository.getRepositoryDirectory]
|
||||
* Checks if this [File] is in the password repository directory as given by
|
||||
* [PasswordRepository.getRepositoryDirectory]
|
||||
*/
|
||||
fun File.isInsideRepository(): Boolean {
|
||||
return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath)
|
||||
return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively lists the files in this [File], skipping any directories it encounters.
|
||||
*/
|
||||
/** Recursively lists the files in this [File], skipping any directories it encounters. */
|
||||
fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList()
|
||||
|
||||
/**
|
||||
|
@ -67,7 +54,7 @@ fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toLis
|
|||
* @see RevCommit.getId
|
||||
*/
|
||||
val RevCommit.hash: String
|
||||
get() = ObjectId.toString(id)
|
||||
get() = ObjectId.toString(id)
|
||||
|
||||
/**
|
||||
* Time this commit was made with second precision.
|
||||
|
@ -75,16 +62,16 @@ val RevCommit.hash: String
|
|||
* @see RevCommit.commitTime
|
||||
*/
|
||||
val RevCommit.time: Date
|
||||
get() {
|
||||
val epochSeconds = commitTime.toLong()
|
||||
val epochMilliseconds = epochSeconds * 1000
|
||||
return Date(epochMilliseconds)
|
||||
}
|
||||
get() {
|
||||
val epochSeconds = commitTime.toLong()
|
||||
val epochMilliseconds = epochSeconds * 1000
|
||||
return Date(epochMilliseconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits this [String] into an [Array] of [String]s, split on the UNIX LF line ending
|
||||
* and stripped of any empty lines.
|
||||
* Splits this [String] into an [Array] of [String] s, split on the UNIX LF line ending and stripped
|
||||
* of any empty lines.
|
||||
*/
|
||||
fun String.splitLines(): Array<String> {
|
||||
return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
|
||||
}
|
||||
|
|
|
@ -11,31 +11,31 @@ import androidx.fragment.app.FragmentManager
|
|||
import androidx.fragment.app.commit
|
||||
import dev.msfjarvis.aps.R
|
||||
|
||||
/**
|
||||
* Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally.
|
||||
*/
|
||||
/** Check if [permission] is granted to the app. Aliases to [isPermissionGranted] internally. */
|
||||
fun Fragment.isPermissionGranted(permission: String): Boolean {
|
||||
return requireActivity().isPermissionGranted(permission)
|
||||
return requireActivity().isPermissionGranted(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity]
|
||||
*/
|
||||
/** Calls `finish()` on the enclosing [androidx.fragment.app.FragmentActivity] */
|
||||
fun Fragment.finish() = requireActivity().finish()
|
||||
|
||||
/**
|
||||
* Perform a [commit] on this [FragmentManager] with custom animations and adding the [destinationFragment]
|
||||
* to the fragment backstack
|
||||
* Perform a [commit] on this [FragmentManager] with custom animations and adding the
|
||||
* [destinationFragment] to the fragment backstack
|
||||
*/
|
||||
fun FragmentManager.performTransactionWithBackStack(destinationFragment: Fragment, @IdRes containerViewId: Int = android.R.id.content) {
|
||||
commit {
|
||||
beginTransaction()
|
||||
addToBackStack(destinationFragment.tag)
|
||||
setCustomAnimations(
|
||||
R.animator.slide_in_left,
|
||||
R.animator.slide_out_left,
|
||||
R.animator.slide_in_right,
|
||||
R.animator.slide_out_right)
|
||||
replace(containerViewId, destinationFragment)
|
||||
}
|
||||
fun FragmentManager.performTransactionWithBackStack(
|
||||
destinationFragment: Fragment,
|
||||
@IdRes containerViewId: Int = android.R.id.content
|
||||
) {
|
||||
commit {
|
||||
beginTransaction()
|
||||
addToBackStack(destinationFragment.tag)
|
||||
setCustomAnimations(
|
||||
R.animator.slide_in_left,
|
||||
R.animator.slide_out_left,
|
||||
R.animator.slide_in_right,
|
||||
R.animator.slide_out_right
|
||||
)
|
||||
replace(containerViewId, destinationFragment)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
package dev.msfjarvis.aps.util.extensions
|
||||
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
@ -18,48 +17,49 @@ import kotlin.properties.ReadOnlyProperty
|
|||
import kotlin.reflect.KProperty
|
||||
|
||||
/**
|
||||
* Imported from https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
|
||||
* Imported from
|
||||
* https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
|
||||
*/
|
||||
class FragmentViewBindingDelegate<T : ViewBinding>(
|
||||
val fragment: Fragment,
|
||||
val viewBindingFactory: (View) -> T
|
||||
) : ReadOnlyProperty<Fragment, T> {
|
||||
class FragmentViewBindingDelegate<T : ViewBinding>(val fragment: Fragment, val viewBindingFactory: (View) -> T) :
|
||||
ReadOnlyProperty<Fragment, T> {
|
||||
|
||||
private var binding: T? = null
|
||||
private var binding: T? = null
|
||||
|
||||
init {
|
||||
fragment.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
|
||||
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
binding = null
|
||||
}
|
||||
})
|
||||
init {
|
||||
fragment.lifecycle.addObserver(
|
||||
object : DefaultLifecycleObserver {
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
|
||||
viewLifecycleOwner.lifecycle.addObserver(
|
||||
object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
binding = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
|
||||
val binding = binding
|
||||
if (binding != null) {
|
||||
return binding
|
||||
}
|
||||
|
||||
override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
|
||||
val binding = binding
|
||||
if (binding != null) {
|
||||
return binding
|
||||
}
|
||||
|
||||
val lifecycle = fragment.viewLifecycleOwner.lifecycle
|
||||
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
|
||||
throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
|
||||
}
|
||||
|
||||
return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
|
||||
val lifecycle = fragment.viewLifecycleOwner.lifecycle
|
||||
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
|
||||
throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
|
||||
}
|
||||
|
||||
return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
|
||||
FragmentViewBindingDelegate(this, viewBindingFactory)
|
||||
FragmentViewBindingDelegate(this, viewBindingFactory)
|
||||
|
||||
inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) =
|
||||
lazy(LazyThreadSafetyMode.NONE) {
|
||||
bindingInflater.invoke(layoutInflater)
|
||||
}
|
||||
lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) }
|
||||
|
|
|
@ -12,57 +12,54 @@ import dev.msfjarvis.aps.R
|
|||
import java.net.UnknownHostException
|
||||
|
||||
/**
|
||||
* Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute].
|
||||
* Supertype for all Git-related [Exception] s that can be thrown by [GitCommandExecutor.execute].
|
||||
*/
|
||||
sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) {
|
||||
|
||||
override val message = super.message!!
|
||||
override val message = super.message!!
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt)
|
||||
}
|
||||
private fun buildMessage(@StringRes res: Int, vararg fmt: String) =
|
||||
Application.instance.resources.getString(res, *fmt)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand].
|
||||
*/
|
||||
sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
|
||||
/** Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. */
|
||||
sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
|
||||
|
||||
object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error)
|
||||
object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error)
|
||||
}
|
||||
object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error)
|
||||
object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand].
|
||||
*/
|
||||
sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
|
||||
/** Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. */
|
||||
sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
|
||||
|
||||
object NonFastForward : PushException(R.string.git_push_nff_error)
|
||||
object RemoteRejected : PushException(R.string.git_push_other_error)
|
||||
class Generic(message: String) : PushException(R.string.git_push_generic_error, message)
|
||||
}
|
||||
object NonFastForward : PushException(R.string.git_push_nff_error)
|
||||
object RemoteRejected : PushException(R.string.git_push_other_error)
|
||||
class Generic(message: String) : PushException(R.string.git_push_generic_error, message)
|
||||
}
|
||||
}
|
||||
|
||||
object ErrorMessages {
|
||||
|
||||
operator fun get(throwable: Throwable?): String {
|
||||
val resources = Application.instance.resources
|
||||
if (throwable == null) return resources.getString(R.string.git_unknown_error)
|
||||
return when (val rootCause = rootCause(throwable)) {
|
||||
is GitException -> rootCause.message
|
||||
is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message)
|
||||
else -> throwable.message ?: resources.getString(R.string.git_unknown_error)
|
||||
}
|
||||
operator fun get(throwable: Throwable?): String {
|
||||
val resources = Application.instance.resources
|
||||
if (throwable == null) return resources.getString(R.string.git_unknown_error)
|
||||
return when (val rootCause = rootCause(throwable)) {
|
||||
is GitException -> rootCause.message
|
||||
is UnknownHostException -> resources.getString(R.string.git_unknown_host, throwable.message)
|
||||
else -> throwable.message ?: resources.getString(R.string.git_unknown_error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rootCause(throwable: Throwable): Throwable {
|
||||
var cause = throwable
|
||||
while (cause.cause != null) {
|
||||
if (cause is GitException) break
|
||||
val nextCause = cause.cause!!
|
||||
if (nextCause is RemoteException) break
|
||||
cause = nextCause
|
||||
}
|
||||
return cause
|
||||
private fun rootCause(throwable: Throwable): Throwable {
|
||||
var cause = throwable
|
||||
while (cause.cause != null) {
|
||||
if (cause is GitException) break
|
||||
val nextCause = cause.cause!!
|
||||
if (nextCause is RemoteException) break
|
||||
cause = nextCause
|
||||
}
|
||||
return cause
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,96 +26,87 @@ import org.eclipse.jgit.lib.PersonIdent
|
|||
import org.eclipse.jgit.transport.RemoteRefUpdate
|
||||
|
||||
class GitCommandExecutor(
|
||||
private val activity: FragmentActivity,
|
||||
private val operation: GitOperation,
|
||||
private val activity: FragmentActivity,
|
||||
private val operation: GitOperation,
|
||||
) {
|
||||
|
||||
suspend fun execute(): Result<Unit, Throwable> {
|
||||
val snackbar = activity.snackbar(
|
||||
message = activity.resources.getString(R.string.git_operation_running),
|
||||
length = Snackbar.LENGTH_INDEFINITE,
|
||||
)
|
||||
// Count the number of uncommitted files
|
||||
var nbChanges = 0
|
||||
return runCatching {
|
||||
for (command in operation.commands) {
|
||||
when (command) {
|
||||
is StatusCommand -> {
|
||||
val res = withContext(Dispatchers.IO) {
|
||||
command.call()
|
||||
}
|
||||
nbChanges = res.uncommittedChanges.size
|
||||
}
|
||||
is CommitCommand -> {
|
||||
// the previous status will eventually be used to avoid a commit
|
||||
if (nbChanges > 0) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val name = GitSettings.authorName.ifEmpty { "root" }
|
||||
val email = GitSettings.authorEmail.ifEmpty { "localhost" }
|
||||
val identity = PersonIdent(name, email)
|
||||
command.setAuthor(identity).setCommitter(identity).call()
|
||||
}
|
||||
}
|
||||
}
|
||||
is PullCommand -> {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
command.call()
|
||||
}
|
||||
if (result.rebaseResult != null) {
|
||||
if (!result.rebaseResult.status.isSuccessful) {
|
||||
throw PullException.PullRebaseFailed
|
||||
}
|
||||
} else if (result.mergeResult != null) {
|
||||
if (!result.mergeResult.mergeStatus.isSuccessful) {
|
||||
throw PullException.PullMergeFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
is PushCommand -> {
|
||||
val results = withContext(Dispatchers.IO) {
|
||||
command.call()
|
||||
}
|
||||
for (result in results) {
|
||||
// Code imported (modified) from Gerrit PushOp, license Apache v2
|
||||
for (rru in result.remoteUpdates) {
|
||||
when (rru.status) {
|
||||
RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward
|
||||
RemoteRefUpdate.Status.REJECTED_NODELETE,
|
||||
RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
|
||||
RemoteRefUpdate.Status.NON_EXISTING,
|
||||
RemoteRefUpdate.Status.NOT_ATTEMPTED,
|
||||
-> throw PushException.Generic(rru.status.name)
|
||||
RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
|
||||
throw if ("non-fast-forward" == rru.message) {
|
||||
PushException.RemoteRejected
|
||||
} else {
|
||||
PushException.Generic(rru.message)
|
||||
}
|
||||
}
|
||||
RemoteRefUpdate.Status.UP_TO_DATE -> {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
activity.applicationContext,
|
||||
activity.applicationContext.getString(R.string.git_push_up_to_date),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
command.call()
|
||||
}
|
||||
}
|
||||
}
|
||||
suspend fun execute(): Result<Unit, Throwable> {
|
||||
val snackbar =
|
||||
activity.snackbar(
|
||||
message = activity.resources.getString(R.string.git_operation_running),
|
||||
length = Snackbar.LENGTH_INDEFINITE,
|
||||
)
|
||||
// Count the number of uncommitted files
|
||||
var nbChanges = 0
|
||||
return runCatching {
|
||||
for (command in operation.commands) {
|
||||
when (command) {
|
||||
is StatusCommand -> {
|
||||
val res = withContext(Dispatchers.IO) { command.call() }
|
||||
nbChanges = res.uncommittedChanges.size
|
||||
}
|
||||
is CommitCommand -> {
|
||||
// the previous status will eventually be used to avoid a commit
|
||||
if (nbChanges > 0) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val name = GitSettings.authorName.ifEmpty { "root" }
|
||||
val email = GitSettings.authorEmail.ifEmpty { "localhost" }
|
||||
val identity = PersonIdent(name, email)
|
||||
command.setAuthor(identity).setCommitter(identity).call()
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
snackbar.dismiss()
|
||||
}
|
||||
is PullCommand -> {
|
||||
val result = withContext(Dispatchers.IO) { command.call() }
|
||||
if (result.rebaseResult != null) {
|
||||
if (!result.rebaseResult.status.isSuccessful) {
|
||||
throw PullException.PullRebaseFailed
|
||||
}
|
||||
} else if (result.mergeResult != null) {
|
||||
if (!result.mergeResult.mergeStatus.isSuccessful) {
|
||||
throw PullException.PullMergeFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
is PushCommand -> {
|
||||
val results = withContext(Dispatchers.IO) { command.call() }
|
||||
for (result in results) {
|
||||
// Code imported (modified) from Gerrit PushOp, license Apache v2
|
||||
for (rru in result.remoteUpdates) {
|
||||
when (rru.status) {
|
||||
RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward
|
||||
RemoteRefUpdate.Status.REJECTED_NODELETE,
|
||||
RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
|
||||
RemoteRefUpdate.Status.NON_EXISTING,
|
||||
RemoteRefUpdate.Status.NOT_ATTEMPTED, -> throw PushException.Generic(rru.status.name)
|
||||
RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
|
||||
throw if ("non-fast-forward" == rru.message) {
|
||||
PushException.RemoteRejected
|
||||
} else {
|
||||
PushException.Generic(rru.message)
|
||||
}
|
||||
}
|
||||
RemoteRefUpdate.Status.UP_TO_DATE -> {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
activity.applicationContext,
|
||||
activity.applicationContext.getString(R.string.git_push_up_to_date),
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
withContext(Dispatchers.IO) { command.call() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.also { snackbar.dismiss() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,41 +15,37 @@ import org.eclipse.jgit.api.Git
|
|||
import org.eclipse.jgit.revwalk.RevCommit
|
||||
|
||||
private fun commits(): Iterable<RevCommit> {
|
||||
val repo = PasswordRepository.getRepository(null)
|
||||
if (repo == null) {
|
||||
e { "Could not access git repository" }
|
||||
return listOf()
|
||||
}
|
||||
return runCatching {
|
||||
Git(repo).log().call()
|
||||
}.getOrElse { e ->
|
||||
e(e) { "Failed to obtain git commits" }
|
||||
listOf()
|
||||
}
|
||||
val repo = PasswordRepository.getRepository(null)
|
||||
if (repo == null) {
|
||||
e { "Could not access git repository" }
|
||||
return listOf()
|
||||
}
|
||||
return runCatching { Git(repo).log().call() }.getOrElse { e ->
|
||||
e(e) { "Failed to obtain git commits" }
|
||||
listOf()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides [GitCommit]s from a git-log of the password git repository.
|
||||
* Provides [GitCommit] s from a git-log of the password git repository.
|
||||
*
|
||||
* All commits are acquired on the first request to this object.
|
||||
*/
|
||||
class GitLogModel {
|
||||
|
||||
// All commits are acquired here at once. Acquiring the commits in batches would not have been
|
||||
// entirely sensible because the amount of computation required to obtain commit number n from
|
||||
// the log includes the amount of computation required to obtain commit number n-1 from the log.
|
||||
// This is because the commit graph is walked from HEAD to the last commit to obtain.
|
||||
// Additionally, tests with 1000 commits in the log have not produced a significant delay in the
|
||||
// user experience.
|
||||
private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) {
|
||||
commits().map {
|
||||
GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time)
|
||||
}.toMutableList()
|
||||
}
|
||||
val size = cache.size
|
||||
// All commits are acquired here at once. Acquiring the commits in batches would not have been
|
||||
// entirely sensible because the amount of computation required to obtain commit number n from
|
||||
// the log includes the amount of computation required to obtain commit number n-1 from the log.
|
||||
// This is because the commit graph is walked from HEAD to the last commit to obtain.
|
||||
// Additionally, tests with 1000 commits in the log have not produced a significant delay in the
|
||||
// user experience.
|
||||
private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) {
|
||||
commits().map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }.toMutableList()
|
||||
}
|
||||
val size = cache.size
|
||||
|
||||
fun get(index: Int): GitCommit? {
|
||||
if (index >= size) e { "Cannot get git commit with index $index. There are only $size." }
|
||||
return cache.getOrNull(index)
|
||||
}
|
||||
fun get(index: Int): GitCommit? {
|
||||
if (index >= size) e { "Cannot get git commit with index $index. There are only $size." }
|
||||
return cache.getOrNull(index)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,44 +13,45 @@ import org.eclipse.jgit.lib.RepositoryState
|
|||
|
||||
class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
|
||||
|
||||
private val merging = repository.repositoryState == RepositoryState.MERGING
|
||||
private val resetCommands = arrayOf(
|
||||
// git checkout -b conflict-branch
|
||||
git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"),
|
||||
// push the changes
|
||||
git.push().setRemote("origin"),
|
||||
// switch back to ${gitBranch}
|
||||
git.checkout().setName(remoteBranch),
|
||||
private val merging = repository.repositoryState == RepositoryState.MERGING
|
||||
private val resetCommands =
|
||||
arrayOf(
|
||||
// git checkout -b conflict-branch
|
||||
git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"),
|
||||
// push the changes
|
||||
git.push().setRemote("origin"),
|
||||
// switch back to ${gitBranch}
|
||||
git.checkout().setName(remoteBranch),
|
||||
)
|
||||
|
||||
override val commands by lazy(LazyThreadSafetyMode.NONE) {
|
||||
if (merging) {
|
||||
// We need to run some non-command operations first
|
||||
repository.writeMergeCommitMsg(null)
|
||||
repository.writeMergeHeads(null)
|
||||
arrayOf(
|
||||
// reset hard back to our local HEAD
|
||||
git.reset().setMode(ResetCommand.ResetType.HARD),
|
||||
*resetCommands,
|
||||
)
|
||||
} else {
|
||||
arrayOf(
|
||||
// abort the rebase
|
||||
git.rebase().setOperation(RebaseCommand.Operation.ABORT),
|
||||
*resetCommands,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun preExecute() = if (!git.repository.repositoryState.isRebasing && !merging) {
|
||||
MaterialAlertDialogBuilder(callingActivity)
|
||||
.setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
|
||||
.setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded))
|
||||
.setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ ->
|
||||
callingActivity.finish()
|
||||
}.show()
|
||||
false
|
||||
override val commands by lazy(LazyThreadSafetyMode.NONE) {
|
||||
if (merging) {
|
||||
// We need to run some non-command operations first
|
||||
repository.writeMergeCommitMsg(null)
|
||||
repository.writeMergeHeads(null)
|
||||
arrayOf(
|
||||
// reset hard back to our local HEAD
|
||||
git.reset().setMode(ResetCommand.ResetType.HARD),
|
||||
*resetCommands,
|
||||
)
|
||||
} else {
|
||||
true
|
||||
arrayOf(
|
||||
// abort the rebase
|
||||
git.rebase().setOperation(RebaseCommand.Operation.ABORT),
|
||||
*resetCommands,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun preExecute() =
|
||||
if (!git.repository.repositoryState.isRebasing && !merging) {
|
||||
MaterialAlertDialogBuilder(callingActivity)
|
||||
.setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
|
||||
.setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded))
|
||||
.setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() }
|
||||
.show()
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,8 @@ import org.eclipse.jgit.api.GitCommand
|
|||
*/
|
||||
class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) {
|
||||
|
||||
override val commands: Array<GitCommand<out Any>> = arrayOf(
|
||||
Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri),
|
||||
override val commands: Array<GitCommand<out Any>> =
|
||||
arrayOf(
|
||||
Git.cloneRepository().setBranch(remoteBranch).setDirectory(repository.workTree).setURI(uri),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -24,80 +24,71 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
|||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class CredentialFinder(
|
||||
val callingActivity: FragmentActivity,
|
||||
val authMode: AuthMode
|
||||
) : InteractivePasswordFinder() {
|
||||
class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) : InteractivePasswordFinder() {
|
||||
|
||||
override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
|
||||
val gitOperationPrefs = callingActivity.getEncryptedGitPrefs()
|
||||
val credentialPref: String
|
||||
@StringRes val messageRes: Int
|
||||
@StringRes val hintRes: Int
|
||||
@StringRes val rememberRes: Int
|
||||
@StringRes val errorRes: Int
|
||||
when (authMode) {
|
||||
AuthMode.SshKey -> {
|
||||
credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
|
||||
messageRes = R.string.passphrase_dialog_text
|
||||
hintRes = R.string.ssh_keygen_passphrase
|
||||
rememberRes = R.string.git_operation_remember_passphrase
|
||||
errorRes = R.string.git_operation_wrong_passphrase
|
||||
}
|
||||
AuthMode.Password -> {
|
||||
// Could be either an SSH or an HTTPS password
|
||||
credentialPref = PreferenceKeys.HTTPS_PASSWORD
|
||||
messageRes = R.string.password_dialog_text
|
||||
hintRes = R.string.git_operation_hint_password
|
||||
rememberRes = R.string.git_operation_remember_password
|
||||
errorRes = R.string.git_operation_wrong_password
|
||||
}
|
||||
else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords")
|
||||
}
|
||||
if (isRetry)
|
||||
gitOperationPrefs.edit { remove(credentialPref) }
|
||||
val storedCredential = gitOperationPrefs.getString(credentialPref, null)
|
||||
if (storedCredential == null) {
|
||||
val layoutInflater = LayoutInflater.from(callingActivity)
|
||||
|
||||
@SuppressLint("InflateParams")
|
||||
val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
|
||||
val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
|
||||
val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
|
||||
editCredential.setHint(hintRes)
|
||||
val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
|
||||
rememberCredential.setText(rememberRes)
|
||||
if (isRetry) {
|
||||
credentialLayout.error = callingActivity.resources.getString(errorRes)
|
||||
// Reset error when user starts entering a password
|
||||
editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
|
||||
}
|
||||
MaterialAlertDialogBuilder(callingActivity).run {
|
||||
setTitle(R.string.passphrase_dialog_title)
|
||||
setMessage(messageRes)
|
||||
setView(dialogView)
|
||||
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||
val credential = editCredential.text.toString()
|
||||
if (rememberCredential.isChecked) {
|
||||
gitOperationPrefs.edit {
|
||||
putString(credentialPref, credential)
|
||||
}
|
||||
}
|
||||
cont.resume(credential)
|
||||
}
|
||||
setNegativeButton(R.string.dialog_cancel) { _, _ ->
|
||||
cont.resume(null)
|
||||
}
|
||||
setOnCancelListener {
|
||||
cont.resume(null)
|
||||
}
|
||||
create()
|
||||
}.run {
|
||||
requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential)
|
||||
show()
|
||||
}
|
||||
} else {
|
||||
cont.resume(storedCredential)
|
||||
}
|
||||
override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
|
||||
val gitOperationPrefs = callingActivity.getEncryptedGitPrefs()
|
||||
val credentialPref: String
|
||||
@StringRes val messageRes: Int
|
||||
@StringRes val hintRes: Int
|
||||
@StringRes val rememberRes: Int
|
||||
@StringRes val errorRes: Int
|
||||
when (authMode) {
|
||||
AuthMode.SshKey -> {
|
||||
credentialPref = PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE
|
||||
messageRes = R.string.passphrase_dialog_text
|
||||
hintRes = R.string.ssh_keygen_passphrase
|
||||
rememberRes = R.string.git_operation_remember_passphrase
|
||||
errorRes = R.string.git_operation_wrong_passphrase
|
||||
}
|
||||
AuthMode.Password -> {
|
||||
// Could be either an SSH or an HTTPS password
|
||||
credentialPref = PreferenceKeys.HTTPS_PASSWORD
|
||||
messageRes = R.string.password_dialog_text
|
||||
hintRes = R.string.git_operation_hint_password
|
||||
rememberRes = R.string.git_operation_remember_password
|
||||
errorRes = R.string.git_operation_wrong_password
|
||||
}
|
||||
else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords")
|
||||
}
|
||||
if (isRetry) gitOperationPrefs.edit { remove(credentialPref) }
|
||||
val storedCredential = gitOperationPrefs.getString(credentialPref, null)
|
||||
if (storedCredential == null) {
|
||||
val layoutInflater = LayoutInflater.from(callingActivity)
|
||||
|
||||
@SuppressLint("InflateParams") val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
|
||||
val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
|
||||
val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
|
||||
editCredential.setHint(hintRes)
|
||||
val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
|
||||
rememberCredential.setText(rememberRes)
|
||||
if (isRetry) {
|
||||
credentialLayout.error = callingActivity.resources.getString(errorRes)
|
||||
// Reset error when user starts entering a password
|
||||
editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
|
||||
}
|
||||
MaterialAlertDialogBuilder(callingActivity)
|
||||
.run {
|
||||
setTitle(R.string.passphrase_dialog_title)
|
||||
setMessage(messageRes)
|
||||
setView(dialogView)
|
||||
setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||
val credential = editCredential.text.toString()
|
||||
if (rememberCredential.isChecked) {
|
||||
gitOperationPrefs.edit { putString(credentialPref, credential) }
|
||||
}
|
||||
cont.resume(credential)
|
||||
}
|
||||
setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) }
|
||||
setOnCancelListener { cont.resume(null) }
|
||||
create()
|
||||
}
|
||||
.run {
|
||||
requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential)
|
||||
show()
|
||||
}
|
||||
} else {
|
||||
cont.resume(storedCredential)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,170 +50,167 @@ import org.eclipse.jgit.transport.URIish
|
|||
*/
|
||||
abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
||||
|
||||
abstract val commands: Array<GitCommand<out Any>>
|
||||
private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
|
||||
private var sshSessionFactory: SshjSessionFactory? = null
|
||||
abstract val commands: Array<GitCommand<out Any>>
|
||||
private val hostKeyFile = callingActivity.filesDir.resolve(".host_key")
|
||||
private var sshSessionFactory: SshjSessionFactory? = null
|
||||
|
||||
protected val repository = PasswordRepository.getRepository(null)!!
|
||||
protected val git = Git(repository)
|
||||
protected val remoteBranch = GitSettings.branch
|
||||
private val authActivity get() = callingActivity as ContinuationContainerActivity
|
||||
protected val repository = PasswordRepository.getRepository(null)!!
|
||||
protected val git = Git(repository)
|
||||
protected val remoteBranch = GitSettings.branch
|
||||
private val authActivity
|
||||
get() = callingActivity as ContinuationContainerActivity
|
||||
|
||||
private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() {
|
||||
private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() {
|
||||
|
||||
private var cachedPassword: CharArray? = null
|
||||
private var cachedPassword: CharArray? = null
|
||||
|
||||
override fun isInteractive() = true
|
||||
override fun isInteractive() = true
|
||||
|
||||
override fun get(uri: URIish?, vararg items: CredentialItem): Boolean {
|
||||
for (item in items) {
|
||||
when (item) {
|
||||
is CredentialItem.Username -> item.value = uri?.user
|
||||
is CredentialItem.Password -> {
|
||||
item.value = cachedPassword?.clone()
|
||||
?: passwordFinder.reqPassword(null).also {
|
||||
cachedPassword = it.clone()
|
||||
}
|
||||
}
|
||||
else -> UnsupportedCredentialItem(uri, item.javaClass.name)
|
||||
override fun get(uri: URIish?, vararg items: CredentialItem): Boolean {
|
||||
for (item in items) {
|
||||
when (item) {
|
||||
is CredentialItem.Username -> item.value = uri?.user
|
||||
is CredentialItem.Password -> {
|
||||
item.value =
|
||||
cachedPassword?.clone() ?: passwordFinder.reqPassword(null).also { cachedPassword = it.clone() }
|
||||
}
|
||||
else -> UnsupportedCredentialItem(uri, item.javaClass.name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun supports(vararg items: CredentialItem) =
|
||||
items.all { it is CredentialItem.Username || it is CredentialItem.Password }
|
||||
|
||||
override fun reset(uri: URIish?) {
|
||||
cachedPassword?.fill(0.toChar())
|
||||
cachedPassword = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSshKey(make: Boolean) {
|
||||
runCatching {
|
||||
val intent =
|
||||
if (make) {
|
||||
Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java)
|
||||
} else {
|
||||
Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java)
|
||||
}
|
||||
callingActivity.startActivity(intent)
|
||||
}
|
||||
.onFailure { e -> e(e) }
|
||||
}
|
||||
|
||||
private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) {
|
||||
sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
|
||||
commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
|
||||
command.setTransportConfigCallback { transport: Transport ->
|
||||
(transport as? SshTransport)?.sshSessionFactory = sshSessionFactory
|
||||
credentialsProvider?.let { transport.credentialsProvider = it }
|
||||
}
|
||||
command.setTimeout(CONNECT_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
/** Executes the GitCommand in an async task. */
|
||||
suspend fun execute(): Result<Unit, Throwable> {
|
||||
if (!preExecute()) {
|
||||
return Ok(Unit)
|
||||
}
|
||||
val operationResult =
|
||||
GitCommandExecutor(
|
||||
callingActivity,
|
||||
this,
|
||||
)
|
||||
.execute()
|
||||
postExecute()
|
||||
return operationResult
|
||||
}
|
||||
|
||||
private fun onMissingSshKeyFile() {
|
||||
MaterialAlertDialogBuilder(callingActivity)
|
||||
.setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
|
||||
.setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
|
||||
.setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ ->
|
||||
getSshKey(false)
|
||||
}
|
||||
.setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ ->
|
||||
getSshKey(true)
|
||||
}
|
||||
.setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
|
||||
// Finish the blank GitActivity so user doesn't have to press back
|
||||
callingActivity.finish()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
|
||||
when (authMode) {
|
||||
AuthMode.SshKey ->
|
||||
if (SshKey.exists) {
|
||||
if (SshKey.mustAuthenticate) {
|
||||
val result =
|
||||
withContext(Dispatchers.Main) {
|
||||
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
|
||||
BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
|
||||
if (it !is BiometricAuthenticator.Result.Failure) cont.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun supports(vararg items: CredentialItem) = items.all {
|
||||
it is CredentialItem.Username || it is CredentialItem.Password
|
||||
}
|
||||
|
||||
override fun reset(uri: URIish?) {
|
||||
cachedPassword?.fill(0.toChar())
|
||||
cachedPassword = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSshKey(make: Boolean) {
|
||||
runCatching {
|
||||
val intent = if (make) {
|
||||
Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java)
|
||||
} else {
|
||||
Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java)
|
||||
}
|
||||
callingActivity.startActivity(intent)
|
||||
}.onFailure { e ->
|
||||
e(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) {
|
||||
sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
|
||||
commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
|
||||
command.setTransportConfigCallback { transport: Transport ->
|
||||
(transport as? SshTransport)?.sshSessionFactory = sshSessionFactory
|
||||
credentialsProvider?.let { transport.credentialsProvider = it }
|
||||
}
|
||||
command.setTimeout(CONNECT_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the GitCommand in an async task.
|
||||
*/
|
||||
suspend fun execute(): Result<Unit, Throwable> {
|
||||
if (!preExecute()) {
|
||||
return Ok(Unit)
|
||||
}
|
||||
val operationResult = GitCommandExecutor(
|
||||
callingActivity,
|
||||
this,
|
||||
).execute()
|
||||
postExecute()
|
||||
return operationResult
|
||||
}
|
||||
|
||||
private fun onMissingSshKeyFile() {
|
||||
MaterialAlertDialogBuilder(callingActivity)
|
||||
.setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
|
||||
.setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
|
||||
.setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ ->
|
||||
getSshKey(false)
|
||||
}
|
||||
.setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ ->
|
||||
getSshKey(true)
|
||||
}
|
||||
.setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
|
||||
// Finish the blank GitActivity so user doesn't have to press back
|
||||
callingActivity.finish()
|
||||
}.show()
|
||||
}
|
||||
|
||||
suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
|
||||
when (authMode) {
|
||||
AuthMode.SshKey -> if (SshKey.exists) {
|
||||
if (SshKey.mustAuthenticate) {
|
||||
val result = withContext(Dispatchers.Main) {
|
||||
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
|
||||
BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
|
||||
if (it !is BiometricAuthenticator.Result.Failure)
|
||||
cont.resume(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
when (result) {
|
||||
is BiometricAuthenticator.Result.Success -> {
|
||||
registerAuthProviders(SshAuthMethod.SshKey(authActivity))
|
||||
}
|
||||
is BiometricAuthenticator.Result.Cancelled -> {
|
||||
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
||||
}
|
||||
is BiometricAuthenticator.Result.Failure -> {
|
||||
throw IllegalStateException("Biometric authentication failures should be ignored")
|
||||
}
|
||||
else -> {
|
||||
// There is a chance we succeed if the user recently confirmed
|
||||
// their screen lock. Doing so would have a potential to confuse
|
||||
// users though, who might deduce that the screen lock
|
||||
// protection is not effective. Hence, we fail with an error.
|
||||
Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show()
|
||||
callingActivity.finish()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
registerAuthProviders(SshAuthMethod.SshKey(authActivity))
|
||||
}
|
||||
} else {
|
||||
onMissingSshKeyFile()
|
||||
// This would correctly cancel the operation but won't surface a user-visible
|
||||
// error, allowing users to make the SSH key selection.
|
||||
}
|
||||
when (result) {
|
||||
is BiometricAuthenticator.Result.Success -> {
|
||||
registerAuthProviders(SshAuthMethod.SshKey(authActivity))
|
||||
}
|
||||
is BiometricAuthenticator.Result.Cancelled -> {
|
||||
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
||||
}
|
||||
is BiometricAuthenticator.Result.Failure -> {
|
||||
throw IllegalStateException("Biometric authentication failures should be ignored")
|
||||
}
|
||||
else -> {
|
||||
// There is a chance we succeed if the user recently confirmed
|
||||
// their screen lock. Doing so would have a potential to confuse
|
||||
// users though, who might deduce that the screen lock
|
||||
// protection is not effective. Hence, we fail with an error.
|
||||
Toast.makeText(
|
||||
callingActivity.applicationContext,
|
||||
R.string.biometric_auth_generic_failure,
|
||||
Toast.LENGTH_LONG
|
||||
)
|
||||
.show()
|
||||
callingActivity.finish()
|
||||
}
|
||||
}
|
||||
AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity))
|
||||
AuthMode.Password -> {
|
||||
val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password))
|
||||
registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider)
|
||||
}
|
||||
AuthMode.None -> {
|
||||
}
|
||||
} else {
|
||||
registerAuthProviders(SshAuthMethod.SshKey(authActivity))
|
||||
}
|
||||
} else {
|
||||
onMissingSshKeyFile()
|
||||
// This would correctly cancel the operation but won't surface a user-visible
|
||||
// error, allowing users to make the SSH key selection.
|
||||
return Err(SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
||||
}
|
||||
return execute()
|
||||
AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity))
|
||||
AuthMode.Password -> {
|
||||
val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password))
|
||||
registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider)
|
||||
}
|
||||
AuthMode.None -> {}
|
||||
}
|
||||
return execute()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before execution of the Git operation.
|
||||
* Return false to cancel.
|
||||
*/
|
||||
open fun preExecute() = true
|
||||
/** Called before execution of the Git operation. Return false to cancel. */
|
||||
open fun preExecute() = true
|
||||
|
||||
private suspend fun postExecute() {
|
||||
withContext(Dispatchers.IO) {
|
||||
sshSessionFactory?.close()
|
||||
}
|
||||
}
|
||||
private suspend fun postExecute() {
|
||||
withContext(Dispatchers.IO) { sshSessionFactory?.close() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Timeout in seconds before [TransportCommand] will abort a stalled IO operation.
|
||||
*/
|
||||
private const val CONNECT_TIMEOUT = 10
|
||||
}
|
||||
/** Timeout in seconds before [TransportCommand] will abort a stalled IO operation. */
|
||||
private const val CONNECT_TIMEOUT = 10
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,27 +8,28 @@ import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
|
|||
import org.eclipse.jgit.api.GitCommand
|
||||
|
||||
class PullOperation(
|
||||
callingActivity: ContinuationContainerActivity,
|
||||
rebase: Boolean,
|
||||
callingActivity: ContinuationContainerActivity,
|
||||
rebase: Boolean,
|
||||
) : GitOperation(callingActivity) {
|
||||
|
||||
/**
|
||||
* The story of why the pull operation is committing files goes like this: Once upon a time when
|
||||
* the world was burning and Blade Runner 2049 was real life (in the worst way), we were made
|
||||
* aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing.
|
||||
* So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation]
|
||||
* and then a [PushOperation]. To make the behavior identical despite this suboptimal situation,
|
||||
* we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
|
||||
* replicating [SyncOperation] but leaving the pushing part to [PushOperation].
|
||||
*/
|
||||
override val commands: Array<GitCommand<out Any>> = arrayOf(
|
||||
// Stage all files
|
||||
git.add().addFilepattern("."),
|
||||
// Populate the changed files count
|
||||
git.status(),
|
||||
// Commit everything! If needed, obviously.
|
||||
git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
|
||||
// Pull and rebase on top of the remote branch
|
||||
git.pull().setRebase(rebase).setRemote("origin"),
|
||||
/**
|
||||
* The story of why the pull operation is committing files goes like this: Once upon a time when
|
||||
* the world was burning and Blade Runner 2049 was real life (in the worst way), we were made
|
||||
* aware that Bitbucket is actually bad, and disables a neat OpenSSH feature called multiplexing.
|
||||
* So now, rather than being able to do a [SyncOperation], we'd have to first do a [PullOperation]
|
||||
* and then a [PushOperation]. To make the behavior identical despite this suboptimal situation,
|
||||
* we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
|
||||
* replicating [SyncOperation] but leaving the pushing part to [PushOperation].
|
||||
*/
|
||||
override val commands: Array<GitCommand<out Any>> =
|
||||
arrayOf(
|
||||
// Stage all files
|
||||
git.add().addFilepattern("."),
|
||||
// Populate the changed files count
|
||||
git.status(),
|
||||
// Commit everything! If needed, obviously.
|
||||
git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
|
||||
// Pull and rebase on top of the remote branch
|
||||
git.pull().setRebase(rebase).setRemote("origin"),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -9,7 +9,8 @@ import org.eclipse.jgit.api.GitCommand
|
|||
|
||||
class PushOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
|
||||
|
||||
override val commands: Array<GitCommand<out Any>> = arrayOf(
|
||||
git.push().setPushAll().setRemote("origin"),
|
||||
override val commands: Array<GitCommand<out Any>> =
|
||||
arrayOf(
|
||||
git.push().setPushAll().setRemote("origin"),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -9,15 +9,18 @@ import org.eclipse.jgit.api.ResetCommand
|
|||
|
||||
class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
|
||||
|
||||
override val commands = arrayOf(
|
||||
// Stage all files
|
||||
git.add().addFilepattern("."),
|
||||
// Fetch everything from the origin remote
|
||||
git.fetch().setRemote("origin"),
|
||||
// Do a hard reset to the remote branch. Equivalent to git reset --hard origin/$remoteBranch
|
||||
git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD),
|
||||
// Force-create $remoteBranch if it doesn't exist. This covers the case where you switched
|
||||
// branches from 'master' to anything else.
|
||||
git.branchCreate().setName(remoteBranch).setForce(true),
|
||||
override val commands =
|
||||
arrayOf(
|
||||
// Stage all files
|
||||
git.add().addFilepattern("."),
|
||||
// Fetch everything from the origin remote
|
||||
git.fetch().setRemote("origin"),
|
||||
// Do a hard reset to the remote branch. Equivalent to git reset --hard
|
||||
// origin/$remoteBranch
|
||||
git.reset().setRef("origin/$remoteBranch").setMode(ResetCommand.ResetType.HARD),
|
||||
// Force-create $remoteBranch if it doesn't exist. This covers the case where you
|
||||
// switched
|
||||
// branches from 'master' to anything else.
|
||||
git.branchCreate().setName(remoteBranch).setForce(true),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,20 +7,21 @@ package dev.msfjarvis.aps.util.git.operation
|
|||
import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
|
||||
|
||||
class SyncOperation(
|
||||
callingActivity: ContinuationContainerActivity,
|
||||
rebase: Boolean,
|
||||
callingActivity: ContinuationContainerActivity,
|
||||
rebase: Boolean,
|
||||
) : GitOperation(callingActivity) {
|
||||
|
||||
override val commands = arrayOf(
|
||||
// Stage all files
|
||||
git.add().addFilepattern("."),
|
||||
// Populate the changed files count
|
||||
git.status(),
|
||||
// Commit everything! If needed, obviously.
|
||||
git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
|
||||
// Pull and rebase on top of the remote branch
|
||||
git.pull().setRebase(rebase).setRemote("origin"),
|
||||
// Push it all back
|
||||
git.push().setPushAll().setRemote("origin"),
|
||||
override val commands =
|
||||
arrayOf(
|
||||
// Stage all files
|
||||
git.add().addFilepattern("."),
|
||||
// Populate the changed files count
|
||||
git.status(),
|
||||
// Commit everything! If needed, obviously.
|
||||
git.commit().setAll(true).setMessage("[Android Password Store] Sync"),
|
||||
// Pull and rebase on top of the remote branch
|
||||
git.pull().setRebase(rebase).setRemote("origin"),
|
||||
// Push it all back
|
||||
git.push().setPushAll().setRemote("origin"),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,24 +14,21 @@ import kotlin.coroutines.resumeWithException
|
|||
import net.schmizz.sshj.common.DisconnectReason
|
||||
import net.schmizz.sshj.userauth.UserAuthException
|
||||
|
||||
/**
|
||||
* Workaround for https://msfjarvis.dev/aps/issue/1164
|
||||
*/
|
||||
/** Workaround for https://msfjarvis.dev/aps/issue/1164 */
|
||||
open class ContinuationContainerActivity : AppCompatActivity {
|
||||
|
||||
constructor() : super()
|
||||
constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
|
||||
constructor() : super()
|
||||
constructor(@LayoutRes layoutRes: Int) : super(layoutRes)
|
||||
|
||||
var stashedCont: Continuation<Intent>? = null
|
||||
var stashedCont: Continuation<Intent>? = null
|
||||
|
||||
val continueAfterUserInteraction = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
|
||||
stashedCont?.let { cont ->
|
||||
stashedCont = null
|
||||
val data = result.data
|
||||
if (data != null)
|
||||
cont.resume(data)
|
||||
else
|
||||
cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
||||
}
|
||||
val continueAfterUserInteraction =
|
||||
registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result ->
|
||||
stashedCont?.let { cont ->
|
||||
stashedCont = null
|
||||
val data = result.data
|
||||
if (data != null) cont.resume(data)
|
||||
else cont.resumeWithException(UserAuthException(DisconnectReason.AUTH_CANCELLED_BY_USER))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,162 +38,170 @@ import org.openintents.ssh.authentication.response.Response
|
|||
import org.openintents.ssh.authentication.response.SigningResponse
|
||||
import org.openintents.ssh.authentication.response.SshPublicKeyResponse
|
||||
|
||||
class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : KeyProvider, Closeable {
|
||||
class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) :
|
||||
KeyProvider, Closeable {
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
suspend fun prepareAndUse(activity: ContinuationContainerActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) {
|
||||
withContext(Dispatchers.Main) {
|
||||
OpenKeychainKeyProvider(activity)
|
||||
}.prepareAndUse(block)
|
||||
}
|
||||
suspend fun prepareAndUse(
|
||||
activity: ContinuationContainerActivity,
|
||||
block: (provider: OpenKeychainKeyProvider) -> Unit
|
||||
) {
|
||||
withContext(Dispatchers.Main) { OpenKeychainKeyProvider(activity) }.prepareAndUse(block)
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ApiResponse {
|
||||
data class Success(val response: Response) : ApiResponse()
|
||||
data class GeneralError(val exception: Exception) : ApiResponse()
|
||||
data class NoSuchKey(val exception: Exception) : ApiResponse()
|
||||
private sealed class ApiResponse {
|
||||
data class Success(val response: Response) : ApiResponse()
|
||||
data class GeneralError(val exception: Exception) : ApiResponse()
|
||||
data class NoSuchKey(val exception: Exception) : ApiResponse()
|
||||
}
|
||||
|
||||
private val context = activity.applicationContext
|
||||
private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER)
|
||||
private val preferences = context.sharedPrefs
|
||||
private lateinit var sshServiceApi: SshAuthenticationApi
|
||||
|
||||
private var keyId
|
||||
get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)
|
||||
set(value) {
|
||||
preferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) }
|
||||
}
|
||||
private var publicKey: PublicKey? = null
|
||||
private var privateKey: OpenKeychainPrivateKey? = null
|
||||
|
||||
private val context = activity.applicationContext
|
||||
private val sshServiceConnection = SshAuthenticationConnection(context, OPENPGP_PROVIDER)
|
||||
private val preferences = context.sharedPrefs
|
||||
private lateinit var sshServiceApi: SshAuthenticationApi
|
||||
private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) {
|
||||
prepare()
|
||||
use(block)
|
||||
}
|
||||
|
||||
private var keyId
|
||||
get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)
|
||||
set(value) {
|
||||
preferences.edit {
|
||||
putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value)
|
||||
private suspend fun prepare() {
|
||||
sshServiceApi =
|
||||
suspendCoroutine { cont ->
|
||||
sshServiceConnection.connect(
|
||||
object : SshAuthenticationConnection.OnBound {
|
||||
override fun onBound(sshAgent: ISshAuthenticationService) {
|
||||
d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" }
|
||||
cont.resume(SshAuthenticationApi(context, sshAgent))
|
||||
}
|
||||
}
|
||||
private var publicKey: PublicKey? = null
|
||||
private var privateKey: OpenKeychainPrivateKey? = null
|
||||
|
||||
private suspend fun prepareAndUse(block: (provider: OpenKeychainKeyProvider) -> Unit) {
|
||||
prepare()
|
||||
use(block)
|
||||
}
|
||||
|
||||
private suspend fun prepare() {
|
||||
sshServiceApi = suspendCoroutine { cont ->
|
||||
sshServiceConnection.connect(object : SshAuthenticationConnection.OnBound {
|
||||
override fun onBound(sshAgent: ISshAuthenticationService) {
|
||||
d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" }
|
||||
cont.resume(SshAuthenticationApi(context, sshAgent))
|
||||
}
|
||||
|
||||
override fun onError() {
|
||||
throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (keyId == null) {
|
||||
selectKey()
|
||||
}
|
||||
check(keyId != null)
|
||||
fetchPublicKey()
|
||||
makePrivateKey()
|
||||
}
|
||||
|
||||
private suspend fun fetchPublicKey(isRetry: Boolean = false) {
|
||||
when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) {
|
||||
is ApiResponse.Success -> {
|
||||
val response = sshPublicKeyResponse.response as SshPublicKeyResponse
|
||||
val sshPublicKey = response.sshPublicKey!!
|
||||
publicKey = parseSshPublicKey(sshPublicKey)
|
||||
?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
|
||||
override fun onError() {
|
||||
throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
|
||||
}
|
||||
is ApiResponse.NoSuchKey -> if (isRetry) {
|
||||
throw sshPublicKeyResponse.exception
|
||||
} else {
|
||||
// Allow the user to reselect an authentication key and retry
|
||||
selectKey()
|
||||
fetchPublicKey(true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (keyId == null) {
|
||||
selectKey()
|
||||
}
|
||||
check(keyId != null)
|
||||
fetchPublicKey()
|
||||
makePrivateKey()
|
||||
}
|
||||
|
||||
private suspend fun fetchPublicKey(isRetry: Boolean = false) {
|
||||
when (val sshPublicKeyResponse = executeApiRequest(SshPublicKeyRequest(keyId))) {
|
||||
is ApiResponse.Success -> {
|
||||
val response = sshPublicKeyResponse.response as SshPublicKeyResponse
|
||||
val sshPublicKey = response.sshPublicKey!!
|
||||
publicKey =
|
||||
parseSshPublicKey(sshPublicKey) ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
|
||||
}
|
||||
is ApiResponse.NoSuchKey ->
|
||||
if (isRetry) {
|
||||
throw sshPublicKeyResponse.exception
|
||||
} else {
|
||||
// Allow the user to reselect an authentication key and retry
|
||||
selectKey()
|
||||
fetchPublicKey(true)
|
||||
}
|
||||
is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun selectKey() {
|
||||
when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) {
|
||||
is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId
|
||||
is ApiResponse.GeneralError -> throw keySelectionResponse.exception
|
||||
is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse {
|
||||
d { "executeRequest($request) called" }
|
||||
val result =
|
||||
withContext(Dispatchers.Main) {
|
||||
// If the request required user interaction, the data returned from the
|
||||
// PendingIntent
|
||||
// is used as the real request.
|
||||
sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!!
|
||||
}
|
||||
return parseResult(request, result).also { d { "executeRequest($request): $it" } }
|
||||
}
|
||||
|
||||
private suspend fun parseResult(request: Request, result: Intent): ApiResponse {
|
||||
return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) {
|
||||
SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
|
||||
ApiResponse.Success(
|
||||
when (request) {
|
||||
is KeySelectionRequest -> KeySelectionResponse(result)
|
||||
is SshPublicKeyRequest -> SshPublicKeyResponse(result)
|
||||
is SigningRequest -> SigningResponse(result)
|
||||
else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
|
||||
}
|
||||
)
|
||||
}
|
||||
SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
|
||||
val resultOfUserInteraction: Intent =
|
||||
withContext(Dispatchers.Main) {
|
||||
suspendCoroutine { cont ->
|
||||
activity.stashedCont = cont
|
||||
activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build())
|
||||
}
|
||||
is ApiResponse.GeneralError -> throw sshPublicKeyResponse.exception
|
||||
}
|
||||
executeApiRequest(request, resultOfUserInteraction)
|
||||
}
|
||||
else -> {
|
||||
val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
|
||||
val exception =
|
||||
UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}")
|
||||
when (error?.error) {
|
||||
SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY ->
|
||||
ApiResponse.NoSuchKey(exception)
|
||||
else -> ApiResponse.GeneralError(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun selectKey() {
|
||||
when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) {
|
||||
is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId
|
||||
is ApiResponse.GeneralError -> throw keySelectionResponse.exception
|
||||
is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception
|
||||
}
|
||||
private fun makePrivateKey() {
|
||||
check(keyId != null && publicKey != null)
|
||||
privateKey =
|
||||
object : OpenKeychainPrivateKey {
|
||||
override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
|
||||
when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) {
|
||||
is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
|
||||
is ApiResponse.GeneralError -> throw signingResponse.exception
|
||||
is ApiResponse.NoSuchKey -> throw signingResponse.exception
|
||||
}
|
||||
|
||||
override fun getAlgorithm() = publicKey!!.algorithm
|
||||
override fun getParams() = (publicKey as? ECKey)?.params
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
activity.lifecycleScope.launch {
|
||||
withContext(Dispatchers.Main) { activity.continueAfterUserInteraction.unregister() }
|
||||
}
|
||||
sshServiceConnection.disconnect()
|
||||
}
|
||||
|
||||
private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse {
|
||||
d { "executeRequest($request) called" }
|
||||
val result = withContext(Dispatchers.Main) {
|
||||
// If the request required user interaction, the data returned from the PendingIntent
|
||||
// is used as the real request.
|
||||
sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!!
|
||||
}
|
||||
return parseResult(request, result).also {
|
||||
d { "executeRequest($request): $it" }
|
||||
}
|
||||
}
|
||||
override fun getPrivate() = privateKey
|
||||
|
||||
private suspend fun parseResult(request: Request, result: Intent): ApiResponse {
|
||||
return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) {
|
||||
SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
|
||||
ApiResponse.Success(when (request) {
|
||||
is KeySelectionRequest -> KeySelectionResponse(result)
|
||||
is SshPublicKeyRequest -> SshPublicKeyResponse(result)
|
||||
is SigningRequest -> SigningResponse(result)
|
||||
else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
|
||||
})
|
||||
}
|
||||
SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||
val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
|
||||
val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) {
|
||||
suspendCoroutine { cont ->
|
||||
activity.stashedCont = cont
|
||||
activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build())
|
||||
}
|
||||
}
|
||||
executeApiRequest(request, resultOfUserInteraction)
|
||||
}
|
||||
else -> {
|
||||
val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
|
||||
val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}")
|
||||
when (error?.error) {
|
||||
SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception)
|
||||
else -> ApiResponse.GeneralError(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun getPublic() = publicKey
|
||||
|
||||
private fun makePrivateKey() {
|
||||
check(keyId != null && publicKey != null)
|
||||
privateKey = object : OpenKeychainPrivateKey {
|
||||
override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
|
||||
when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) {
|
||||
is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
|
||||
is ApiResponse.GeneralError -> throw signingResponse.exception
|
||||
is ApiResponse.NoSuchKey -> throw signingResponse.exception
|
||||
}
|
||||
|
||||
override fun getAlgorithm() = publicKey!!.algorithm
|
||||
override fun getParams() = (publicKey as? ECKey)?.params
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
activity.lifecycleScope.launch {
|
||||
withContext(Dispatchers.Main) {
|
||||
activity.continueAfterUserInteraction.unregister()
|
||||
}
|
||||
}
|
||||
sshServiceConnection.disconnect()
|
||||
}
|
||||
|
||||
override fun getPrivate() = privateKey
|
||||
|
||||
override fun getPublic() = publicKey
|
||||
|
||||
override fun getType(): KeyType = KeyType.fromKey(publicKey)
|
||||
override fun getType(): KeyType = KeyType.fromKey(publicKey)
|
||||
}
|
||||
|
|
|
@ -8,8 +8,6 @@ import com.hierynomus.sshj.key.KeyAlgorithm
|
|||
import java.io.ByteArrayOutputStream
|
||||
import java.security.PrivateKey
|
||||
import java.security.interfaces.ECKey
|
||||
import java.security.interfaces.ECPrivateKey
|
||||
import java.security.spec.ECParameterSpec
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.schmizz.sshj.common.Buffer
|
||||
import net.schmizz.sshj.common.Factory
|
||||
|
@ -18,79 +16,83 @@ import org.openintents.ssh.authentication.SshAuthenticationApi
|
|||
|
||||
interface OpenKeychainPrivateKey : PrivateKey, ECKey {
|
||||
|
||||
suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray
|
||||
suspend fun sign(challenge: ByteArray, hashAlgorithm: Int): ByteArray
|
||||
|
||||
override fun getFormat() = null
|
||||
override fun getEncoded() = null
|
||||
override fun getFormat() = null
|
||||
override fun getEncoded() = null
|
||||
}
|
||||
|
||||
class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) : Factory.Named<KeyAlgorithm> by factory {
|
||||
class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) :
|
||||
Factory.Named<KeyAlgorithm> by factory {
|
||||
|
||||
override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create())
|
||||
override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create())
|
||||
}
|
||||
|
||||
class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm {
|
||||
|
||||
private val hashAlgorithm = when (keyAlgorithm.keyAlgorithm) {
|
||||
"rsa-sha2-512" -> SshAuthenticationApi.SHA512
|
||||
"rsa-sha2-256" -> SshAuthenticationApi.SHA256
|
||||
"ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1
|
||||
// Other algorithms don't use this value, but it has to be valid.
|
||||
else -> SshAuthenticationApi.SHA512
|
||||
private val hashAlgorithm =
|
||||
when (keyAlgorithm.keyAlgorithm) {
|
||||
"rsa-sha2-512" -> SshAuthenticationApi.SHA512
|
||||
"rsa-sha2-256" -> SshAuthenticationApi.SHA256
|
||||
"ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1
|
||||
// Other algorithms don't use this value, but it has to be valid.
|
||||
else -> SshAuthenticationApi.SHA512
|
||||
}
|
||||
|
||||
override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
|
||||
override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
|
||||
}
|
||||
|
||||
class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) : Signature by wrappedSignature {
|
||||
class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) :
|
||||
Signature by wrappedSignature {
|
||||
|
||||
private val data = ByteArrayOutputStream()
|
||||
private val data = ByteArrayOutputStream()
|
||||
|
||||
private var bridgedPrivateKey: OpenKeychainPrivateKey? = null
|
||||
private var bridgedPrivateKey: OpenKeychainPrivateKey? = null
|
||||
|
||||
override fun initSign(prvkey: PrivateKey?) {
|
||||
if (prvkey is OpenKeychainPrivateKey) {
|
||||
bridgedPrivateKey = prvkey
|
||||
} else {
|
||||
wrappedSignature.initSign(prvkey)
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(H: ByteArray?) {
|
||||
if (bridgedPrivateKey != null) {
|
||||
data.write(H!!)
|
||||
} else {
|
||||
wrappedSignature.update(H)
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(H: ByteArray?, off: Int, len: Int) {
|
||||
if (bridgedPrivateKey != null) {
|
||||
data.write(H!!, off, len)
|
||||
} else {
|
||||
wrappedSignature.update(H, off, len)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sign(): ByteArray? = if (bridgedPrivateKey != null) {
|
||||
runBlocking {
|
||||
bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm)
|
||||
}
|
||||
override fun initSign(prvkey: PrivateKey?) {
|
||||
if (prvkey is OpenKeychainPrivateKey) {
|
||||
bridgedPrivateKey = prvkey
|
||||
} else {
|
||||
wrappedSignature.sign()
|
||||
wrappedSignature.initSign(prvkey)
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(H: ByteArray?) {
|
||||
if (bridgedPrivateKey != null) {
|
||||
data.write(H!!)
|
||||
} else {
|
||||
wrappedSignature.update(H)
|
||||
}
|
||||
}
|
||||
|
||||
override fun update(H: ByteArray?, off: Int, len: Int) {
|
||||
if (bridgedPrivateKey != null) {
|
||||
data.write(H!!, off, len)
|
||||
} else {
|
||||
wrappedSignature.update(H, off, len)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sign(): ByteArray? =
|
||||
if (bridgedPrivateKey != null) {
|
||||
runBlocking { bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) }
|
||||
} else {
|
||||
wrappedSignature.sign()
|
||||
}
|
||||
|
||||
override fun encode(signature: ByteArray?): ByteArray? = if (bridgedPrivateKey != null) {
|
||||
require(signature != null) { "OpenKeychain signature must not be null" }
|
||||
val encodedSignature = Buffer.PlainBuffer(signature)
|
||||
// We need to drop the algorithm name and extract the raw signature since SSHJ adds the name
|
||||
// later.
|
||||
encodedSignature.readString()
|
||||
encodedSignature.readBytes().also {
|
||||
bridgedPrivateKey = null
|
||||
data.reset()
|
||||
}
|
||||
override fun encode(signature: ByteArray?): ByteArray? =
|
||||
if (bridgedPrivateKey != null) {
|
||||
require(signature != null) { "OpenKeychain signature must not be null" }
|
||||
val encodedSignature = Buffer.PlainBuffer(signature)
|
||||
// We need to drop the algorithm name and extract the raw signature since SSHJ adds the
|
||||
// name
|
||||
// later.
|
||||
encodedSignature.readString()
|
||||
encodedSignature.readBytes().also {
|
||||
bridgedPrivateKey = null
|
||||
data.reset()
|
||||
}
|
||||
} else {
|
||||
wrappedSignature.encode(signature)
|
||||
wrappedSignature.encode(signature)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -51,286 +51,288 @@ private const val KEYSTORE_ALIAS = "sshkey"
|
|||
private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "androidx_sshkey_keyset_prefs"
|
||||
|
||||
private val androidKeystore: KeyStore by lazy(LazyThreadSafetyMode.NONE) {
|
||||
KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
|
||||
KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) }
|
||||
}
|
||||
|
||||
private val KeyStore.sshPrivateKey
|
||||
get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
|
||||
get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey
|
||||
|
||||
private val KeyStore.sshPublicKey
|
||||
get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
|
||||
get() = getCertificate(KEYSTORE_ALIAS)?.publicKey
|
||||
|
||||
fun parseSshPublicKey(sshPublicKey: String): PublicKey? {
|
||||
val sshKeyParts = sshPublicKey.split("""\s+""".toRegex())
|
||||
if (sshKeyParts.size < 2)
|
||||
return null
|
||||
return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
|
||||
val sshKeyParts = sshPublicKey.split("""\s+""".toRegex())
|
||||
if (sshKeyParts.size < 2) return null
|
||||
return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
|
||||
}
|
||||
|
||||
fun toSshPublicKey(publicKey: PublicKey): String {
|
||||
val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData
|
||||
val keyType = KeyType.fromKey(publicKey)
|
||||
return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}"
|
||||
val rawPublicKey = Buffer.PlainBuffer().putPublicKey(publicKey).compactData
|
||||
val keyType = KeyType.fromKey(publicKey)
|
||||
return "$keyType ${Base64.encodeToString(rawPublicKey, Base64.NO_WRAP)}"
|
||||
}
|
||||
|
||||
object SshKey {
|
||||
|
||||
val sshPublicKey
|
||||
get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null
|
||||
val canShowSshPublicKey
|
||||
get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519)
|
||||
val exists
|
||||
get() = type != null
|
||||
val mustAuthenticate: Boolean
|
||||
get() {
|
||||
return runCatching {
|
||||
if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519))
|
||||
return false
|
||||
when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
|
||||
is PrivateKey -> {
|
||||
val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
|
||||
return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired
|
||||
}
|
||||
is SecretKey -> {
|
||||
val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
|
||||
(factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired
|
||||
}
|
||||
else -> throw IllegalStateException("SSH key does not exist in Keystore")
|
||||
}
|
||||
}.getOrElse { error ->
|
||||
// It is fine to swallow the exception here since it will reappear when the key is
|
||||
// used for SSH authentication and can then be shown in the UI.
|
||||
d(error)
|
||||
false
|
||||
}
|
||||
val sshPublicKey
|
||||
get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null
|
||||
val canShowSshPublicKey
|
||||
get() = type in listOf(Type.LegacyGenerated, Type.KeystoreNative, Type.KeystoreWrappedEd25519)
|
||||
val exists
|
||||
get() = type != null
|
||||
val mustAuthenticate: Boolean
|
||||
get() {
|
||||
return runCatching {
|
||||
if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) return false
|
||||
when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
|
||||
is PrivateKey -> {
|
||||
val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
|
||||
return factory.getKeySpec(key, KeyInfo::class.java).isUserAuthenticationRequired
|
||||
}
|
||||
is SecretKey -> {
|
||||
val factory = SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
|
||||
(factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo).isUserAuthenticationRequired
|
||||
}
|
||||
else -> throw IllegalStateException("SSH key does not exist in Keystore")
|
||||
}
|
||||
|
||||
private val context: Context
|
||||
get() = Application.instance.applicationContext
|
||||
|
||||
private val privateKeyFile
|
||||
get() = File(context.filesDir, ".ssh_key")
|
||||
private val publicKeyFile
|
||||
get() = File(context.filesDir, ".ssh_key.pub")
|
||||
|
||||
private var type: Type?
|
||||
get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
|
||||
set(value) = context.sharedPrefs.edit {
|
||||
putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value)
|
||||
}
|
||||
|
||||
private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
private enum class Type(val value: String) {
|
||||
Imported("imported"),
|
||||
KeystoreNative("keystore_native"),
|
||||
KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
|
||||
|
||||
// Behaves like `Imported`, but allows to view the public key.
|
||||
LegacyGenerated("legacy_generated"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
fun fromValue(value: String?): Type? = values().associateBy { it.value }[value]
|
||||
}
|
||||
.getOrElse { error ->
|
||||
// It is fine to swallow the exception here since it will reappear when the key
|
||||
// is
|
||||
// used for SSH authentication and can then be shown in the UI.
|
||||
d(error)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) {
|
||||
Rsa(KeyProperties.KEY_ALGORITHM_RSA, {
|
||||
setKeySize(3072)
|
||||
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
|
||||
setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
|
||||
}),
|
||||
Ecdsa(KeyProperties.KEY_ALGORITHM_EC, {
|
||||
setKeySize(256)
|
||||
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
|
||||
setDigests(KeyProperties.DIGEST_SHA256)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
setIsStrongBoxBacked(isStrongBoxSupported)
|
||||
}
|
||||
}),
|
||||
private val context: Context
|
||||
get() = Application.instance.applicationContext
|
||||
|
||||
private val privateKeyFile
|
||||
get() = File(context.filesDir, ".ssh_key")
|
||||
private val publicKeyFile
|
||||
get() = File(context.filesDir, ".ssh_key.pub")
|
||||
|
||||
private var type: Type?
|
||||
get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
|
||||
set(value) = context.sharedPrefs.edit { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) }
|
||||
|
||||
private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
|
||||
else false
|
||||
}
|
||||
|
||||
private enum class Type(val value: String) {
|
||||
Imported("imported"),
|
||||
KeystoreNative("keystore_native"),
|
||||
KeystoreWrappedEd25519("keystore_wrapped_ed25519"),
|
||||
|
||||
// Behaves like `Imported`, but allows to view the public key.
|
||||
LegacyGenerated("legacy_generated"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
fun fromValue(value: String?): Type? = values().associateBy { it.value }[value]
|
||||
}
|
||||
}
|
||||
|
||||
enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) {
|
||||
Rsa(
|
||||
KeyProperties.KEY_ALGORITHM_RSA,
|
||||
{
|
||||
setKeySize(3072)
|
||||
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
|
||||
setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
|
||||
}
|
||||
),
|
||||
Ecdsa(
|
||||
KeyProperties.KEY_ALGORITHM_EC,
|
||||
{
|
||||
setKeySize(256)
|
||||
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
|
||||
setDigests(KeyProperties.DIGEST_SHA256)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
setIsStrongBoxBacked(isStrongBoxSupported)
|
||||
}
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
private fun delete() {
|
||||
androidKeystore.deleteEntry(KEYSTORE_ALIAS)
|
||||
// Remove Tink key set used by AndroidX's EncryptedFile.
|
||||
context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { clear() }
|
||||
if (privateKeyFile.isFile) {
|
||||
privateKeyFile.delete()
|
||||
}
|
||||
if (publicKeyFile.isFile) {
|
||||
publicKeyFile.delete()
|
||||
}
|
||||
context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
|
||||
type = null
|
||||
}
|
||||
|
||||
fun import(uri: Uri) {
|
||||
// First check whether the content at uri is likely an SSH private key.
|
||||
val fileSize =
|
||||
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
|
||||
// Cursor returns only a single row.
|
||||
cursor.moveToFirst()
|
||||
cursor.getInt(0)
|
||||
}
|
||||
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
|
||||
|
||||
// We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
|
||||
if (fileSize > 100_000 || fileSize == 0)
|
||||
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
|
||||
|
||||
val sshKeyInputStream =
|
||||
context.contentResolver.openInputStream(uri)
|
||||
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
|
||||
val lines = sshKeyInputStream.bufferedReader().readLines()
|
||||
|
||||
// The file must have more than 2 lines, and the first and last line must have private key
|
||||
// markers.
|
||||
if (lines.size < 2 ||
|
||||
!Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
|
||||
!Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
|
||||
)
|
||||
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
|
||||
|
||||
// At this point, we are reasonably confident that we have actually been provided a private
|
||||
// key and delete the old key.
|
||||
delete()
|
||||
// Canonicalize line endings to '\n'.
|
||||
privateKeyFile.writeText(lines.joinToString("\n"))
|
||||
|
||||
type = Type.Imported
|
||||
}
|
||||
|
||||
@Deprecated("To be used only in Migrations.kt")
|
||||
fun useLegacyKey(isGenerated: Boolean) {
|
||||
type = if (isGenerated) Type.LegacyGenerated else Type.Imported
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) =
|
||||
withContext(Dispatchers.IO) {
|
||||
MasterKey.Builder(context, KEYSTORE_ALIAS).run {
|
||||
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
setRequestStrongBoxBacked(true)
|
||||
setUserAuthenticationRequired(requireAuthentication, 15)
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete() {
|
||||
androidKeystore.deleteEntry(KEYSTORE_ALIAS)
|
||||
// Remove Tink key set used by AndroidX's EncryptedFile.
|
||||
context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit {
|
||||
clear()
|
||||
}
|
||||
if (privateKeyFile.isFile) {
|
||||
privateKeyFile.delete()
|
||||
}
|
||||
if (publicKeyFile.isFile) {
|
||||
publicKeyFile.delete()
|
||||
}
|
||||
context.getEncryptedGitPrefs().edit {
|
||||
remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
|
||||
}
|
||||
type = null
|
||||
}
|
||||
|
||||
fun import(uri: Uri) {
|
||||
// First check whether the content at uri is likely an SSH private key.
|
||||
val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)
|
||||
?.use { cursor ->
|
||||
// Cursor returns only a single row.
|
||||
cursor.moveToFirst()
|
||||
cursor.getInt(0)
|
||||
} ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
|
||||
|
||||
// We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
|
||||
if (fileSize > 100_000 || fileSize == 0)
|
||||
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
|
||||
|
||||
val sshKeyInputStream = context.contentResolver.openInputStream(uri)
|
||||
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
|
||||
val lines = sshKeyInputStream.bufferedReader().readLines()
|
||||
|
||||
// The file must have more than 2 lines, and the first and last line must have private key
|
||||
// markers.
|
||||
if (lines.size < 2 ||
|
||||
!Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
|
||||
!Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) =
|
||||
withContext(Dispatchers.IO) {
|
||||
EncryptedFile.Builder(
|
||||
context,
|
||||
privateKeyFile,
|
||||
getOrCreateWrappingMasterKey(requireAuthentication),
|
||||
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
|
||||
)
|
||||
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
|
||||
|
||||
// At this point, we are reasonably confident that we have actually been provided a private
|
||||
// key and delete the old key.
|
||||
delete()
|
||||
// Canonicalize line endings to '\n'.
|
||||
privateKeyFile.writeText(lines.joinToString("\n"))
|
||||
|
||||
type = Type.Imported
|
||||
}
|
||||
|
||||
@Deprecated("To be used only in Migrations.kt")
|
||||
fun useLegacyKey(isGenerated: Boolean) {
|
||||
type = if (isGenerated) Type.LegacyGenerated else Type.Imported
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
|
||||
MasterKey.Builder(context, KEYSTORE_ALIAS).run {
|
||||
setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
setRequestStrongBoxBacked(true)
|
||||
setUserAuthenticationRequired(requireAuthentication, 15)
|
||||
build()
|
||||
.run {
|
||||
setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME)
|
||||
build()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
|
||||
EncryptedFile.Builder(context,
|
||||
privateKeyFile,
|
||||
getOrCreateWrappingMasterKey(requireAuthentication),
|
||||
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).run {
|
||||
setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME)
|
||||
build()
|
||||
}
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) =
|
||||
withContext(Dispatchers.IO) {
|
||||
delete()
|
||||
|
||||
val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication)
|
||||
// Generate the ed25519 key pair and encrypt the private key.
|
||||
val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair()
|
||||
encryptedPrivateKeyFile.openFileOutput().use { os -> os.write((keyPair.private as EdDSAPrivateKey).seed) }
|
||||
|
||||
// Write public key in SSH format to .ssh_key.pub.
|
||||
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
|
||||
|
||||
type = Type.KeystoreWrappedEd25519
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) {
|
||||
delete()
|
||||
fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) {
|
||||
delete()
|
||||
|
||||
val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication)
|
||||
// Generate the ed25519 key pair and encrypt the private key.
|
||||
val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair()
|
||||
encryptedPrivateKeyFile.openFileOutput().use { os ->
|
||||
os.write((keyPair.private as EdDSAPrivateKey).seed)
|
||||
// Generate Keystore-backed private key.
|
||||
val parameterSpec =
|
||||
KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run {
|
||||
apply(algorithm.applyToSpec)
|
||||
if (requireAuthentication) {
|
||||
setUserAuthenticationRequired(true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(30)
|
||||
}
|
||||
}
|
||||
build()
|
||||
}
|
||||
val keyPair =
|
||||
KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run {
|
||||
initialize(parameterSpec)
|
||||
generateKeyPair()
|
||||
}
|
||||
|
||||
// Write public key in SSH format to .ssh_key.pub.
|
||||
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
|
||||
// Write public key in SSH format to .ssh_key.pub.
|
||||
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
|
||||
|
||||
type = Type.KeystoreWrappedEd25519
|
||||
type = Type.KeystoreNative
|
||||
}
|
||||
|
||||
fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? =
|
||||
when (type) {
|
||||
Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder)
|
||||
Type.KeystoreNative -> KeystoreNativeKeyProvider
|
||||
Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider
|
||||
null -> null
|
||||
}
|
||||
|
||||
fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) {
|
||||
delete()
|
||||
private object KeystoreNativeKeyProvider : KeyProvider {
|
||||
|
||||
// Generate Keystore-backed private key.
|
||||
val parameterSpec = KeyGenParameterSpec.Builder(
|
||||
KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN
|
||||
).run {
|
||||
apply(algorithm.applyToSpec)
|
||||
if (requireAuthentication) {
|
||||
setUserAuthenticationRequired(true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
setUserAuthenticationValidityDurationSeconds(30)
|
||||
}
|
||||
}
|
||||
build()
|
||||
}
|
||||
val keyPair = KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run {
|
||||
initialize(parameterSpec)
|
||||
generateKeyPair()
|
||||
override fun getPublic(): PublicKey =
|
||||
runCatching { androidKeystore.sshPublicKey!! }.getOrElse { error ->
|
||||
e(error)
|
||||
throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error)
|
||||
}
|
||||
|
||||
override fun getPrivate(): PrivateKey =
|
||||
runCatching { androidKeystore.sshPrivateKey!! }.getOrElse { error ->
|
||||
e(error)
|
||||
throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error)
|
||||
}
|
||||
|
||||
override fun getType(): KeyType = KeyType.fromKey(public)
|
||||
}
|
||||
|
||||
private object KeystoreWrappedEd25519KeyProvider : KeyProvider {
|
||||
|
||||
override fun getPublic(): PublicKey =
|
||||
runCatching { parseSshPublicKey(sshPublicKey!!)!! }.getOrElse { error ->
|
||||
e(error)
|
||||
throw IOException("Failed to get the public key for wrapped ed25519 key", error)
|
||||
}
|
||||
|
||||
override fun getPrivate(): PrivateKey =
|
||||
runCatching {
|
||||
// The current MasterKey API does not allow getting a reference to an existing one
|
||||
// without specifying the KeySpec for a new one. However, the value for passed here
|
||||
// for `requireAuthentication` is not used as the key already exists at this point.
|
||||
val encryptedPrivateKeyFile = runBlocking { getOrCreateWrappedPrivateKeyFile(false) }
|
||||
val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() }
|
||||
EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC))
|
||||
}
|
||||
.getOrElse { error ->
|
||||
e(error)
|
||||
throw IOException("Failed to unwrap wrapped ed25519 key", error)
|
||||
}
|
||||
|
||||
// Write public key in SSH format to .ssh_key.pub.
|
||||
publicKeyFile.writeText(toSshPublicKey(keyPair.public))
|
||||
|
||||
type = Type.KeystoreNative
|
||||
}
|
||||
|
||||
fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = when (type) {
|
||||
Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder)
|
||||
Type.KeystoreNative -> KeystoreNativeKeyProvider
|
||||
Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider
|
||||
null -> null
|
||||
}
|
||||
|
||||
private object KeystoreNativeKeyProvider : KeyProvider {
|
||||
|
||||
override fun getPublic(): PublicKey = runCatching {
|
||||
androidKeystore.sshPublicKey!!
|
||||
}.getOrElse { error ->
|
||||
e(error)
|
||||
throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error)
|
||||
}
|
||||
|
||||
override fun getPrivate(): PrivateKey = runCatching {
|
||||
androidKeystore.sshPrivateKey!!
|
||||
}.getOrElse { error ->
|
||||
e(error)
|
||||
throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error)
|
||||
}
|
||||
|
||||
override fun getType(): KeyType = KeyType.fromKey(public)
|
||||
}
|
||||
|
||||
private object KeystoreWrappedEd25519KeyProvider : KeyProvider {
|
||||
|
||||
override fun getPublic(): PublicKey = runCatching {
|
||||
parseSshPublicKey(sshPublicKey!!)!!
|
||||
}.getOrElse { error ->
|
||||
e(error)
|
||||
throw IOException("Failed to get the public key for wrapped ed25519 key", error)
|
||||
}
|
||||
|
||||
override fun getPrivate(): PrivateKey = runCatching {
|
||||
// The current MasterKey API does not allow getting a reference to an existing one
|
||||
// without specifying the KeySpec for a new one. However, the value for passed here
|
||||
// for `requireAuthentication` is not used as the key already exists at this point.
|
||||
val encryptedPrivateKeyFile = runBlocking {
|
||||
getOrCreateWrappedPrivateKeyFile(false)
|
||||
}
|
||||
val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() }
|
||||
EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC))
|
||||
}.getOrElse { error ->
|
||||
e(error)
|
||||
throw IOException("Failed to unwrap wrapped ed25519 key", error)
|
||||
}
|
||||
|
||||
override fun getType(): KeyType = KeyType.fromKey(public)
|
||||
}
|
||||
override fun getType(): KeyType = KeyType.fromKey(public)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,250 +33,240 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider
|
|||
import org.slf4j.Logger
|
||||
import org.slf4j.Marker
|
||||
|
||||
|
||||
fun setUpBouncyCastleForSshj() {
|
||||
// Replace the Android BC provider with the Java BouncyCastle provider since the former does
|
||||
// not include all the required algorithms.
|
||||
// Note: This may affect crypto operations in other parts of the application.
|
||||
val bcIndex = Security.getProviders().indexOfFirst {
|
||||
it.name == BouncyCastleProvider.PROVIDER_NAME
|
||||
}
|
||||
if (bcIndex == -1) {
|
||||
// No Android BC found, install Java BC at lowest priority.
|
||||
Security.addProvider(BouncyCastleProvider())
|
||||
} else {
|
||||
// Replace Android BC with Java BC, inserted at the same position.
|
||||
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
|
||||
// May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261
|
||||
runCatching { Class.forName("sun.security.jca.Providers") }
|
||||
Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
|
||||
}
|
||||
d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" }
|
||||
// Prevent sshj from forwarding all cryptographic operations to BC.
|
||||
SecurityUtils.setRegisterBouncyCastle(false)
|
||||
SecurityUtils.setSecurityProvider(null)
|
||||
// Replace the Android BC provider with the Java BouncyCastle provider since the former does
|
||||
// not include all the required algorithms.
|
||||
// Note: This may affect crypto operations in other parts of the application.
|
||||
val bcIndex = Security.getProviders().indexOfFirst { it.name == BouncyCastleProvider.PROVIDER_NAME }
|
||||
if (bcIndex == -1) {
|
||||
// No Android BC found, install Java BC at lowest priority.
|
||||
Security.addProvider(BouncyCastleProvider())
|
||||
} else {
|
||||
// Replace Android BC with Java BC, inserted at the same position.
|
||||
Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME)
|
||||
// May be needed on Android Pie+ as per https://stackoverflow.com/a/57897224/297261
|
||||
runCatching { Class.forName("sun.security.jca.Providers") }
|
||||
Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1)
|
||||
}
|
||||
d { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" }
|
||||
// Prevent sshj from forwarding all cryptographic operations to BC.
|
||||
SecurityUtils.setRegisterBouncyCastle(false)
|
||||
SecurityUtils.setSecurityProvider(null)
|
||||
}
|
||||
|
||||
private abstract class AbstractLogger(private val name: String) : Logger {
|
||||
|
||||
abstract fun t(message: String, t: Throwable? = null, vararg args: Any?)
|
||||
abstract fun d(message: String, t: Throwable? = null, vararg args: Any?)
|
||||
abstract fun i(message: String, t: Throwable? = null, vararg args: Any?)
|
||||
abstract fun w(message: String, t: Throwable? = null, vararg args: Any?)
|
||||
abstract fun e(message: String, t: Throwable? = null, vararg args: Any?)
|
||||
abstract fun t(message: String, t: Throwable? = null, vararg args: Any?)
|
||||
abstract fun d(message: String, t: Throwable? = null, vararg args: Any?)
|
||||
abstract fun i(message: String, t: Throwable? = null, vararg args: Any?)
|
||||
abstract fun w(message: String, t: Throwable? = null, vararg args: Any?)
|
||||
abstract fun e(message: String, t: Throwable? = null, vararg args: Any?)
|
||||
|
||||
override fun getName() = name
|
||||
override fun getName() = name
|
||||
|
||||
override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled
|
||||
override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled
|
||||
override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled
|
||||
override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled
|
||||
override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled
|
||||
override fun isTraceEnabled(marker: Marker?): Boolean = isTraceEnabled
|
||||
override fun isDebugEnabled(marker: Marker?): Boolean = isDebugEnabled
|
||||
override fun isInfoEnabled(marker: Marker?): Boolean = isInfoEnabled
|
||||
override fun isWarnEnabled(marker: Marker?): Boolean = isWarnEnabled
|
||||
override fun isErrorEnabled(marker: Marker?): Boolean = isErrorEnabled
|
||||
|
||||
override fun trace(msg: String) = t(msg)
|
||||
override fun trace(format: String, arg: Any?) = t(format, null, arg)
|
||||
override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2)
|
||||
override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments)
|
||||
override fun trace(msg: String, t: Throwable?) = t(msg, t)
|
||||
override fun trace(marker: Marker, msg: String) = trace(msg)
|
||||
override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg)
|
||||
override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
||||
trace(format, arg1, arg2)
|
||||
override fun trace(msg: String) = t(msg)
|
||||
override fun trace(format: String, arg: Any?) = t(format, null, arg)
|
||||
override fun trace(format: String, arg1: Any?, arg2: Any?) = t(format, null, arg1, arg2)
|
||||
override fun trace(format: String, vararg arguments: Any?) = t(format, null, *arguments)
|
||||
override fun trace(msg: String, t: Throwable?) = t(msg, t)
|
||||
override fun trace(marker: Marker, msg: String) = trace(msg)
|
||||
override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg)
|
||||
override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = trace(format, arg1, arg2)
|
||||
|
||||
override fun trace(marker: Marker?, format: String, vararg arguments: Any?) =
|
||||
trace(format, *arguments)
|
||||
override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = trace(format, *arguments)
|
||||
|
||||
override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t)
|
||||
override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t)
|
||||
|
||||
override fun debug(msg: String) = d(msg)
|
||||
override fun debug(format: String, arg: Any?) = d(format, null, arg)
|
||||
override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2)
|
||||
override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments)
|
||||
override fun debug(msg: String, t: Throwable?) = d(msg, t)
|
||||
override fun debug(marker: Marker, msg: String) = debug(msg)
|
||||
override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg)
|
||||
override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
||||
debug(format, arg1, arg2)
|
||||
override fun debug(msg: String) = d(msg)
|
||||
override fun debug(format: String, arg: Any?) = d(format, null, arg)
|
||||
override fun debug(format: String, arg1: Any?, arg2: Any?) = d(format, null, arg1, arg2)
|
||||
override fun debug(format: String, vararg arguments: Any?) = d(format, null, *arguments)
|
||||
override fun debug(msg: String, t: Throwable?) = d(msg, t)
|
||||
override fun debug(marker: Marker, msg: String) = debug(msg)
|
||||
override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg)
|
||||
override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = debug(format, arg1, arg2)
|
||||
|
||||
override fun debug(marker: Marker?, format: String, vararg arguments: Any?) =
|
||||
debug(format, *arguments)
|
||||
override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = debug(format, *arguments)
|
||||
|
||||
override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t)
|
||||
override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t)
|
||||
|
||||
override fun info(msg: String) = i(msg)
|
||||
override fun info(format: String, arg: Any?) = i(format, null, arg)
|
||||
override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2)
|
||||
override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments)
|
||||
override fun info(msg: String, t: Throwable?) = i(msg, t)
|
||||
override fun info(marker: Marker, msg: String) = info(msg)
|
||||
override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg)
|
||||
override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
||||
info(format, arg1, arg2)
|
||||
override fun info(msg: String) = i(msg)
|
||||
override fun info(format: String, arg: Any?) = i(format, null, arg)
|
||||
override fun info(format: String, arg1: Any?, arg2: Any?) = i(format, null, arg1, arg2)
|
||||
override fun info(format: String, vararg arguments: Any?) = i(format, null, *arguments)
|
||||
override fun info(msg: String, t: Throwable?) = i(msg, t)
|
||||
override fun info(marker: Marker, msg: String) = info(msg)
|
||||
override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg)
|
||||
override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = info(format, arg1, arg2)
|
||||
|
||||
override fun info(marker: Marker?, format: String, vararg arguments: Any?) =
|
||||
info(format, *arguments)
|
||||
override fun info(marker: Marker?, format: String, vararg arguments: Any?) = info(format, *arguments)
|
||||
|
||||
override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t)
|
||||
override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t)
|
||||
|
||||
override fun warn(msg: String) = w(msg)
|
||||
override fun warn(format: String, arg: Any?) = w(format, null, arg)
|
||||
override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2)
|
||||
override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments)
|
||||
override fun warn(msg: String, t: Throwable?) = w(msg, t)
|
||||
override fun warn(marker: Marker, msg: String) = warn(msg)
|
||||
override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg)
|
||||
override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
||||
warn(format, arg1, arg2)
|
||||
override fun warn(msg: String) = w(msg)
|
||||
override fun warn(format: String, arg: Any?) = w(format, null, arg)
|
||||
override fun warn(format: String, arg1: Any?, arg2: Any?) = w(format, null, arg1, arg2)
|
||||
override fun warn(format: String, vararg arguments: Any?) = w(format, null, *arguments)
|
||||
override fun warn(msg: String, t: Throwable?) = w(msg, t)
|
||||
override fun warn(marker: Marker, msg: String) = warn(msg)
|
||||
override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg)
|
||||
override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = warn(format, arg1, arg2)
|
||||
|
||||
override fun warn(marker: Marker?, format: String, vararg arguments: Any?) =
|
||||
warn(format, *arguments)
|
||||
override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = warn(format, *arguments)
|
||||
|
||||
override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t)
|
||||
override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t)
|
||||
|
||||
override fun error(msg: String) = e(msg)
|
||||
override fun error(format: String, arg: Any?) = e(format, null, arg)
|
||||
override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2)
|
||||
override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments)
|
||||
override fun error(msg: String, t: Throwable?) = e(msg, t)
|
||||
override fun error(marker: Marker, msg: String) = error(msg)
|
||||
override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg)
|
||||
override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
||||
error(format, arg1, arg2)
|
||||
override fun error(msg: String) = e(msg)
|
||||
override fun error(format: String, arg: Any?) = e(format, null, arg)
|
||||
override fun error(format: String, arg1: Any?, arg2: Any?) = e(format, null, arg1, arg2)
|
||||
override fun error(format: String, vararg arguments: Any?) = e(format, null, *arguments)
|
||||
override fun error(msg: String, t: Throwable?) = e(msg, t)
|
||||
override fun error(marker: Marker, msg: String) = error(msg)
|
||||
override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg)
|
||||
override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = error(format, arg1, arg2)
|
||||
|
||||
override fun error(marker: Marker?, format: String, vararg arguments: Any?) =
|
||||
error(format, *arguments)
|
||||
override fun error(marker: Marker?, format: String, vararg arguments: Any?) = error(format, *arguments)
|
||||
|
||||
override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t)
|
||||
override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t)
|
||||
}
|
||||
|
||||
object TimberLoggerFactory : LoggerFactory {
|
||||
private class TimberLogger(name: String) : AbstractLogger(name) {
|
||||
private class TimberLogger(name: String) : AbstractLogger(name) {
|
||||
|
||||
// We defer the log level checks to Timber.
|
||||
override fun isTraceEnabled() = true
|
||||
override fun isDebugEnabled() = true
|
||||
override fun isInfoEnabled() = true
|
||||
override fun isWarnEnabled() = true
|
||||
override fun isErrorEnabled() = true
|
||||
// We defer the log level checks to Timber.
|
||||
override fun isTraceEnabled() = true
|
||||
override fun isDebugEnabled() = true
|
||||
override fun isInfoEnabled() = true
|
||||
override fun isWarnEnabled() = true
|
||||
override fun isErrorEnabled() = true
|
||||
|
||||
// Replace slf4j's "{}" format string style with standard Java's "%s".
|
||||
// The supposedly redundant escape on the } is not redundant.
|
||||
@Suppress("RegExpRedundantEscape")
|
||||
private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
|
||||
// Replace slf4j's "{}" format string style with standard Java's "%s".
|
||||
// The supposedly redundant escape on the } is not redundant.
|
||||
@Suppress("RegExpRedundantEscape") private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
|
||||
|
||||
override fun t(message: String, t: Throwable?, vararg args: Any?) {
|
||||
Timber.tag(name).v(t, message.fix(), *args)
|
||||
}
|
||||
|
||||
override fun d(message: String, t: Throwable?, vararg args: Any?) {
|
||||
Timber.tag(name).d(t, message.fix(), *args)
|
||||
}
|
||||
|
||||
override fun i(message: String, t: Throwable?, vararg args: Any?) {
|
||||
Timber.tag(name).i(t, message.fix(), *args)
|
||||
}
|
||||
|
||||
override fun w(message: String, t: Throwable?, vararg args: Any?) {
|
||||
Timber.tag(name).w(t, message.fix(), *args)
|
||||
}
|
||||
|
||||
override fun e(message: String, t: Throwable?, vararg args: Any?) {
|
||||
Timber.tag(name).e(t, message.fix(), *args)
|
||||
}
|
||||
override fun t(message: String, t: Throwable?, vararg args: Any?) {
|
||||
Timber.tag(name).v(t, message.fix(), *args)
|
||||
}
|
||||
|
||||
override fun getLogger(name: String): Logger {
|
||||
return TimberLogger(name)
|
||||
override fun d(message: String, t: Throwable?, vararg args: Any?) {
|
||||
Timber.tag(name).d(t, message.fix(), *args)
|
||||
}
|
||||
|
||||
override fun getLogger(clazz: Class<*>): Logger {
|
||||
return TimberLogger(clazz.name)
|
||||
override fun i(message: String, t: Throwable?, vararg args: Any?) {
|
||||
Timber.tag(name).i(t, message.fix(), *args)
|
||||
}
|
||||
|
||||
override fun w(message: String, t: Throwable?, vararg args: Any?) {
|
||||
Timber.tag(name).w(t, message.fix(), *args)
|
||||
}
|
||||
|
||||
override fun e(message: String, t: Throwable?, vararg args: Any?) {
|
||||
Timber.tag(name).e(t, message.fix(), *args)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getLogger(name: String): Logger {
|
||||
return TimberLogger(name)
|
||||
}
|
||||
|
||||
override fun getLogger(clazz: Class<*>): Logger {
|
||||
return TimberLogger(clazz.name)
|
||||
}
|
||||
}
|
||||
|
||||
class SshjConfig : ConfigImpl() {
|
||||
|
||||
init {
|
||||
loggerFactory = TimberLoggerFactory
|
||||
keepAliveProvider = KeepAliveProvider.HEARTBEAT
|
||||
version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1"
|
||||
init {
|
||||
loggerFactory = TimberLoggerFactory
|
||||
keepAliveProvider = KeepAliveProvider.HEARTBEAT
|
||||
version = "OpenSSH_8.2p1 Ubuntu-4ubuntu0.1"
|
||||
|
||||
initKeyExchangeFactories()
|
||||
initKeyAlgorithms()
|
||||
initRandomFactory()
|
||||
initFileKeyProviderFactories()
|
||||
initCipherFactories()
|
||||
initCompressionFactories()
|
||||
initMACFactories()
|
||||
}
|
||||
initKeyExchangeFactories()
|
||||
initKeyAlgorithms()
|
||||
initRandomFactory()
|
||||
initFileKeyProviderFactories()
|
||||
initCipherFactories()
|
||||
initCompressionFactories()
|
||||
initMACFactories()
|
||||
}
|
||||
|
||||
private fun initKeyExchangeFactories() {
|
||||
keyExchangeFactories = listOf(
|
||||
Curve25519SHA256.Factory(),
|
||||
FactoryLibSsh(),
|
||||
ECDHNistP.Factory521(),
|
||||
ECDHNistP.Factory384(),
|
||||
ECDHNistP.Factory256(),
|
||||
DHGexSHA256.Factory(),
|
||||
// Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get
|
||||
// rsa-sha2-* key types to work with some servers (e.g. GitHub).
|
||||
ExtInfoClientFactory(),
|
||||
)
|
||||
}
|
||||
private fun initKeyExchangeFactories() {
|
||||
keyExchangeFactories =
|
||||
listOf(
|
||||
Curve25519SHA256.Factory(),
|
||||
FactoryLibSsh(),
|
||||
ECDHNistP.Factory521(),
|
||||
ECDHNistP.Factory384(),
|
||||
ECDHNistP.Factory256(),
|
||||
DHGexSHA256.Factory(),
|
||||
// Sends "ext-info-c" with the list of key exchange algorithms. This is needed to
|
||||
// get
|
||||
// rsa-sha2-* key types to work with some servers (e.g. GitHub).
|
||||
ExtInfoClientFactory(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun initKeyAlgorithms() {
|
||||
keyAlgorithms = listOf(
|
||||
KeyAlgorithms.SSHRSACertV01(),
|
||||
KeyAlgorithms.EdDSA25519(),
|
||||
KeyAlgorithms.ECDSASHANistp521(),
|
||||
KeyAlgorithms.ECDSASHANistp384(),
|
||||
KeyAlgorithms.ECDSASHANistp256(),
|
||||
KeyAlgorithms.RSASHA512(),
|
||||
KeyAlgorithms.RSASHA256(),
|
||||
KeyAlgorithms.SSHRSA(),
|
||||
).map {
|
||||
OpenKeychainWrappedKeyAlgorithmFactory(it)
|
||||
}
|
||||
}
|
||||
private fun initKeyAlgorithms() {
|
||||
keyAlgorithms =
|
||||
listOf(
|
||||
KeyAlgorithms.SSHRSACertV01(),
|
||||
KeyAlgorithms.EdDSA25519(),
|
||||
KeyAlgorithms.ECDSASHANistp521(),
|
||||
KeyAlgorithms.ECDSASHANistp384(),
|
||||
KeyAlgorithms.ECDSASHANistp256(),
|
||||
KeyAlgorithms.RSASHA512(),
|
||||
KeyAlgorithms.RSASHA256(),
|
||||
KeyAlgorithms.SSHRSA(),
|
||||
)
|
||||
.map { OpenKeychainWrappedKeyAlgorithmFactory(it) }
|
||||
}
|
||||
|
||||
private fun initRandomFactory() {
|
||||
randomFactory = SingletonRandomFactory(JCERandom.Factory())
|
||||
}
|
||||
private fun initRandomFactory() {
|
||||
randomFactory = SingletonRandomFactory(JCERandom.Factory())
|
||||
}
|
||||
|
||||
private fun initFileKeyProviderFactories() {
|
||||
fileKeyProviderFactories = listOf(
|
||||
OpenSSHKeyV1KeyFile.Factory(),
|
||||
PKCS8KeyFile.Factory(),
|
||||
PKCS5KeyFile.Factory(),
|
||||
OpenSSHKeyFile.Factory(),
|
||||
PuTTYKeyFile.Factory(),
|
||||
)
|
||||
}
|
||||
private fun initFileKeyProviderFactories() {
|
||||
fileKeyProviderFactories =
|
||||
listOf(
|
||||
OpenSSHKeyV1KeyFile.Factory(),
|
||||
PKCS8KeyFile.Factory(),
|
||||
PKCS5KeyFile.Factory(),
|
||||
OpenSSHKeyFile.Factory(),
|
||||
PuTTYKeyFile.Factory(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun initCipherFactories() {
|
||||
cipherFactories =
|
||||
listOf(
|
||||
GcmCiphers.AES128GCM(),
|
||||
GcmCiphers.AES256GCM(),
|
||||
BlockCiphers.AES256CTR(),
|
||||
BlockCiphers.AES192CTR(),
|
||||
BlockCiphers.AES128CTR(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun initCipherFactories() {
|
||||
cipherFactories = listOf(
|
||||
GcmCiphers.AES128GCM(),
|
||||
GcmCiphers.AES256GCM(),
|
||||
BlockCiphers.AES256CTR(),
|
||||
BlockCiphers.AES192CTR(),
|
||||
BlockCiphers.AES128CTR(),
|
||||
)
|
||||
}
|
||||
private fun initMACFactories() {
|
||||
macFactories =
|
||||
listOf(
|
||||
Macs.HMACSHA2512Etm(),
|
||||
Macs.HMACSHA2256Etm(),
|
||||
Macs.HMACSHA2512(),
|
||||
Macs.HMACSHA2256(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun initMACFactories() {
|
||||
macFactories = listOf(
|
||||
Macs.HMACSHA2512Etm(),
|
||||
Macs.HMACSHA2256Etm(),
|
||||
Macs.HMACSHA2512(),
|
||||
Macs.HMACSHA2256(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun initCompressionFactories() {
|
||||
compressionFactories = listOf(
|
||||
NoneCompression.Factory(),
|
||||
)
|
||||
}
|
||||
private fun initCompressionFactories() {
|
||||
compressionFactories =
|
||||
listOf(
|
||||
NoneCompression.Factory(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,158 +40,155 @@ import org.eclipse.jgit.transport.URIish
|
|||
import org.eclipse.jgit.util.FS
|
||||
|
||||
sealed class SshAuthMethod(val activity: ContinuationContainerActivity) {
|
||||
class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||
class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||
class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||
class Password(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||
class SshKey(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||
class OpenKeychain(activity: ContinuationContainerActivity) : SshAuthMethod(activity)
|
||||
}
|
||||
|
||||
abstract class InteractivePasswordFinder : PasswordFinder {
|
||||
|
||||
private var isRetry = false
|
||||
private var isRetry = false
|
||||
|
||||
abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean)
|
||||
abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean)
|
||||
|
||||
final override fun reqPassword(resource: Resource<*>?): CharArray {
|
||||
val password = runBlocking(Dispatchers.Main) {
|
||||
suspendCoroutine<String?> { cont ->
|
||||
askForPassword(cont, isRetry)
|
||||
}
|
||||
}
|
||||
isRetry = true
|
||||
return password?.toCharArray()
|
||||
?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
|
||||
}
|
||||
final override fun reqPassword(resource: Resource<*>?): CharArray {
|
||||
val password = runBlocking(Dispatchers.Main) { suspendCoroutine<String?> { cont -> askForPassword(cont, isRetry) } }
|
||||
isRetry = true
|
||||
return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
|
||||
}
|
||||
|
||||
final override fun shouldRetry(resource: Resource<*>?) = true
|
||||
final override fun shouldRetry(resource: Resource<*>?) = true
|
||||
}
|
||||
|
||||
class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : SshSessionFactory() {
|
||||
|
||||
private var currentSession: SshjSession? = null
|
||||
private var currentSession: SshjSession? = null
|
||||
|
||||
override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession {
|
||||
return currentSession
|
||||
?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
|
||||
d { "New SSH connection created" }
|
||||
currentSession = it
|
||||
}
|
||||
}
|
||||
override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession {
|
||||
return currentSession
|
||||
?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
|
||||
d { "New SSH connection created" }
|
||||
currentSession = it
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
currentSession?.close()
|
||||
}
|
||||
fun close() {
|
||||
currentSession?.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
|
||||
if (!hostKeyFile.exists()) {
|
||||
return HostKeyVerifier { _, _, key ->
|
||||
val digest = runCatching {
|
||||
SecurityUtils.getMessageDigest("SHA-256")
|
||||
}.getOrElse { e ->
|
||||
throw SSHRuntimeException(e)
|
||||
}
|
||||
digest.update(PlainBuffer().putPublicKey(key).compactData)
|
||||
val digestData = digest.digest()
|
||||
val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}"
|
||||
d { "Trusting host key on first use: $hostKeyEntry" }
|
||||
hostKeyFile.writeText(hostKeyEntry)
|
||||
true
|
||||
}
|
||||
} else {
|
||||
val hostKeyEntry = hostKeyFile.readText()
|
||||
d { "Pinned host key: $hostKeyEntry" }
|
||||
return FingerprintVerifier.getInstance(hostKeyEntry)
|
||||
if (!hostKeyFile.exists()) {
|
||||
return HostKeyVerifier { _, _, key ->
|
||||
val digest =
|
||||
runCatching { SecurityUtils.getMessageDigest("SHA-256") }.getOrElse { e -> throw SSHRuntimeException(e) }
|
||||
digest.update(PlainBuffer().putPublicKey(key).compactData)
|
||||
val digestData = digest.digest()
|
||||
val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}"
|
||||
d { "Trusting host key on first use: $hostKeyEntry" }
|
||||
hostKeyFile.writeText(hostKeyEntry)
|
||||
true
|
||||
}
|
||||
} else {
|
||||
val hostKeyEntry = hostKeyFile.readText()
|
||||
d { "Pinned host key: $hostKeyEntry" }
|
||||
return FingerprintVerifier.getInstance(hostKeyEntry)
|
||||
}
|
||||
}
|
||||
|
||||
private class SshjSession(uri: URIish, private val username: String, private val authMethod: SshAuthMethod, private val hostKeyFile: File) : RemoteSession {
|
||||
private class SshjSession(
|
||||
uri: URIish,
|
||||
private val username: String,
|
||||
private val authMethod: SshAuthMethod,
|
||||
private val hostKeyFile: File
|
||||
) : RemoteSession {
|
||||
|
||||
private lateinit var ssh: SSHClient
|
||||
private var currentCommand: Session? = null
|
||||
private lateinit var ssh: SSHClient
|
||||
private var currentCommand: Session? = null
|
||||
|
||||
private val uri = if (uri.host.contains('@')) {
|
||||
// URIish's String constructor cannot handle '@' in the user part of the URI and the URL
|
||||
// constructor can't be used since Java's URL does not recognize the ssh scheme. We thus
|
||||
// need to patch everything up ourselves.
|
||||
d { "Before fixup: user=${uri.user}, host=${uri.host}" }
|
||||
val userPlusHost = "${uri.user}@${uri.host}"
|
||||
val realUser = userPlusHost.substringBeforeLast('@')
|
||||
val realHost = userPlusHost.substringAfterLast('@')
|
||||
uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } }
|
||||
private val uri =
|
||||
if (uri.host.contains('@')) {
|
||||
// URIish's String constructor cannot handle '@' in the user part of the URI and the URL
|
||||
// constructor can't be used since Java's URL does not recognize the ssh scheme. We thus
|
||||
// need to patch everything up ourselves.
|
||||
d { "Before fixup: user=${uri.user}, host=${uri.host}" }
|
||||
val userPlusHost = "${uri.user}@${uri.host}"
|
||||
val realUser = userPlusHost.substringBeforeLast('@')
|
||||
val realHost = userPlusHost.substringAfterLast('@')
|
||||
uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } }
|
||||
} else {
|
||||
uri
|
||||
uri
|
||||
}
|
||||
|
||||
fun connect(): SshjSession {
|
||||
ssh = SSHClient(SshjConfig())
|
||||
ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile))
|
||||
ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
|
||||
if (!ssh.isConnected)
|
||||
throw IOException()
|
||||
val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password))
|
||||
when (authMethod) {
|
||||
is SshAuthMethod.Password -> {
|
||||
ssh.auth(username, passwordAuth)
|
||||
}
|
||||
is SshAuthMethod.SshKey -> {
|
||||
val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
|
||||
ssh.auth(username, pubkeyAuth, passwordAuth)
|
||||
}
|
||||
is SshAuthMethod.OpenKeychain -> {
|
||||
runBlocking {
|
||||
OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider ->
|
||||
val openKeychainAuth = AuthPublickey(provider)
|
||||
ssh.auth(username, openKeychainAuth, passwordAuth)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun connect(): SshjSession {
|
||||
ssh = SSHClient(SshjConfig())
|
||||
ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile))
|
||||
ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
|
||||
if (!ssh.isConnected) throw IOException()
|
||||
val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password))
|
||||
when (authMethod) {
|
||||
is SshAuthMethod.Password -> {
|
||||
ssh.auth(username, passwordAuth)
|
||||
}
|
||||
is SshAuthMethod.SshKey -> {
|
||||
val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
|
||||
ssh.auth(username, pubkeyAuth, passwordAuth)
|
||||
}
|
||||
is SshAuthMethod.OpenKeychain -> {
|
||||
runBlocking {
|
||||
OpenKeychainKeyProvider.prepareAndUse(authMethod.activity) { provider ->
|
||||
val openKeychainAuth = AuthPublickey(provider)
|
||||
ssh.auth(username, openKeychainAuth, passwordAuth)
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
override fun exec(commandName: String?, timeout: Int): Process {
|
||||
if (currentCommand != null) {
|
||||
w { "Killing old command" }
|
||||
disconnect()
|
||||
}
|
||||
val session = ssh.startSession()
|
||||
currentCommand = session
|
||||
return SshjProcess(session.exec(commandName), timeout.toLong())
|
||||
override fun exec(commandName: String?, timeout: Int): Process {
|
||||
if (currentCommand != null) {
|
||||
w { "Killing old command" }
|
||||
disconnect()
|
||||
}
|
||||
val session = ssh.startSession()
|
||||
currentCommand = session
|
||||
return SshjProcess(session.exec(commandName), timeout.toLong())
|
||||
}
|
||||
|
||||
/**
|
||||
* Kills the current command if one is running and returns the session into a state where `exec`
|
||||
* can be called.
|
||||
*
|
||||
* Note that this does *not* disconnect the session. Unfortunately, the function has to be
|
||||
* called `disconnect` to override the corresponding abstract function in `RemoteSession`.
|
||||
*/
|
||||
override fun disconnect() {
|
||||
currentCommand?.close()
|
||||
currentCommand = null
|
||||
}
|
||||
/**
|
||||
* Kills the current command if one is running and returns the session into a state where `exec`
|
||||
* can be called.
|
||||
*
|
||||
* Note that this does *not* disconnect the session. Unfortunately, the function has to be called
|
||||
* `disconnect` to override the corresponding abstract function in `RemoteSession`.
|
||||
*/
|
||||
override fun disconnect() {
|
||||
currentCommand?.close()
|
||||
currentCommand = null
|
||||
}
|
||||
|
||||
fun close() {
|
||||
disconnect()
|
||||
ssh.close()
|
||||
}
|
||||
fun close() {
|
||||
disconnect()
|
||||
ssh.close()
|
||||
}
|
||||
}
|
||||
|
||||
private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() {
|
||||
|
||||
override fun waitFor(): Int {
|
||||
command.join(timeout, TimeUnit.SECONDS)
|
||||
command.close()
|
||||
return exitValue()
|
||||
}
|
||||
override fun waitFor(): Int {
|
||||
command.join(timeout, TimeUnit.SECONDS)
|
||||
command.close()
|
||||
return exitValue()
|
||||
}
|
||||
|
||||
override fun destroy() = command.close()
|
||||
override fun destroy() = command.close()
|
||||
|
||||
override fun getOutputStream(): OutputStream = command.outputStream
|
||||
override fun getOutputStream(): OutputStream = command.outputStream
|
||||
|
||||
override fun getErrorStream(): InputStream = command.errorStream
|
||||
override fun getErrorStream(): InputStream = command.errorStream
|
||||
|
||||
override fun exitValue(): Int = command.exitStatus
|
||||
override fun exitValue(): Int = command.exitStatus
|
||||
|
||||
override fun getInputStream(): InputStream = command.inputStream
|
||||
override fun getInputStream(): InputStream = command.inputStream
|
||||
}
|
||||
|
|
|
@ -15,52 +15,52 @@ import java.net.ProxySelector
|
|||
import java.net.SocketAddress
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Utility class for [Proxy] handling.
|
||||
*/
|
||||
/** Utility class for [Proxy] handling. */
|
||||
object ProxyUtils {
|
||||
|
||||
private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser"
|
||||
private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword"
|
||||
private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser"
|
||||
private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword"
|
||||
|
||||
/**
|
||||
* Set the default [Proxy] and [Authenticator] for the app based on user provided settings.
|
||||
*/
|
||||
fun setDefaultProxy() {
|
||||
ProxySelector.setDefault(object : ProxySelector() {
|
||||
override fun select(uri: URI?): MutableList<Proxy> {
|
||||
val host = GitSettings.proxyHost
|
||||
val port = GitSettings.proxyPort
|
||||
return if (host == null || port == -1) {
|
||||
mutableListOf(Proxy.NO_PROXY)
|
||||
} else {
|
||||
mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port)))
|
||||
}
|
||||
}
|
||||
|
||||
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
|
||||
if (uri == null || sa == null || ioe == null) {
|
||||
throw IllegalArgumentException("Arguments can't be null.")
|
||||
}
|
||||
}
|
||||
})
|
||||
val user = GitSettings.proxyUsername ?: ""
|
||||
val password = GitSettings.proxyPassword ?: ""
|
||||
if (user.isEmpty() || password.isEmpty()) {
|
||||
System.clearProperty(HTTP_PROXY_USER_PROPERTY)
|
||||
System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
|
||||
} else {
|
||||
System.setProperty(HTTP_PROXY_USER_PROPERTY, user)
|
||||
System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password)
|
||||
/** Set the default [Proxy] and [Authenticator] for the app based on user provided settings. */
|
||||
fun setDefaultProxy() {
|
||||
ProxySelector.setDefault(
|
||||
object : ProxySelector() {
|
||||
override fun select(uri: URI?): MutableList<Proxy> {
|
||||
val host = GitSettings.proxyHost
|
||||
val port = GitSettings.proxyPort
|
||||
return if (host == null || port == -1) {
|
||||
mutableListOf(Proxy.NO_PROXY)
|
||||
} else {
|
||||
mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress.createUnresolved(host, port)))
|
||||
}
|
||||
}
|
||||
Authenticator.setDefault(object : Authenticator() {
|
||||
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||
return if (requestorType == RequestorType.PROXY) {
|
||||
PasswordAuthentication(user, password.toCharArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) {
|
||||
if (uri == null || sa == null || ioe == null) {
|
||||
throw IllegalArgumentException("Arguments can't be null.")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
val user = GitSettings.proxyUsername ?: ""
|
||||
val password = GitSettings.proxyPassword ?: ""
|
||||
if (user.isEmpty() || password.isEmpty()) {
|
||||
System.clearProperty(HTTP_PROXY_USER_PROPERTY)
|
||||
System.clearProperty(HTTP_PROXY_PASSWORD_PROPERTY)
|
||||
} else {
|
||||
System.setProperty(HTTP_PROXY_USER_PROPERTY, user)
|
||||
System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password)
|
||||
}
|
||||
Authenticator.setDefault(
|
||||
object : Authenticator() {
|
||||
override fun getPasswordAuthentication(): PasswordAuthentication? {
|
||||
return if (requestorType == RequestorType.PROXY) {
|
||||
PasswordAuthentication(user, password.toCharArray())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,128 +12,118 @@ import dev.msfjarvis.aps.util.extensions.hasFlag
|
|||
import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||
|
||||
enum class PasswordOption(val key: String) {
|
||||
NoDigits("0"),
|
||||
NoUppercaseLetters("A"),
|
||||
NoAmbiguousCharacters("B"),
|
||||
FullyRandom("s"),
|
||||
AtLeastOneSymbol("y"),
|
||||
NoLowercaseLetters("L")
|
||||
NoDigits("0"),
|
||||
NoUppercaseLetters("A"),
|
||||
NoAmbiguousCharacters("B"),
|
||||
FullyRandom("s"),
|
||||
AtLeastOneSymbol("y"),
|
||||
NoLowercaseLetters("L")
|
||||
}
|
||||
|
||||
object PasswordGenerator {
|
||||
|
||||
const val DEFAULT_LENGTH = 16
|
||||
const val DEFAULT_LENGTH = 16
|
||||
|
||||
const val DIGITS = 0x0001
|
||||
const val UPPERS = 0x0002
|
||||
const val SYMBOLS = 0x0004
|
||||
const val NO_AMBIGUOUS = 0x0008
|
||||
const val LOWERS = 0x0020
|
||||
const val DIGITS = 0x0001
|
||||
const val UPPERS = 0x0002
|
||||
const val SYMBOLS = 0x0004
|
||||
const val NO_AMBIGUOUS = 0x0008
|
||||
const val LOWERS = 0x0020
|
||||
|
||||
const val DIGITS_STR = "0123456789"
|
||||
const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz"
|
||||
const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
|
||||
const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2"
|
||||
const val DIGITS_STR = "0123456789"
|
||||
const val UPPERS_STR = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
const val LOWERS_STR = "abcdefghijklmnopqrstuvwxyz"
|
||||
const val SYMBOLS_STR = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
|
||||
const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2"
|
||||
|
||||
/**
|
||||
* Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for
|
||||
* generated passwords.
|
||||
*/
|
||||
fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean {
|
||||
ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
|
||||
for (possibleOption in PasswordOption.values())
|
||||
putBoolean(possibleOption.key, possibleOption in options)
|
||||
putInt("length", targetLength)
|
||||
/**
|
||||
* Enables the [PasswordOption] s in [options] and sets [targetLength] as the length for generated
|
||||
* passwords.
|
||||
*/
|
||||
fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean {
|
||||
ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
|
||||
for (possibleOption in PasswordOption.values()) putBoolean(possibleOption.key, possibleOption in options)
|
||||
putInt("length", targetLength)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun isValidPassword(password: String, pwFlags: Int): Boolean {
|
||||
if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) return false
|
||||
if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) return false
|
||||
if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) return false
|
||||
if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) return false
|
||||
if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** Generates a password using the preferences set by [setPrefs]. */
|
||||
@Throws(PasswordGeneratorException::class)
|
||||
fun generate(ctx: Context): String {
|
||||
val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
||||
var numCharacterCategories = 0
|
||||
|
||||
var phonemes = true
|
||||
var pwgenFlags = DIGITS or UPPERS or LOWERS
|
||||
|
||||
for (option in PasswordOption.values()) {
|
||||
if (prefs.getBoolean(option.key, false)) {
|
||||
when (option) {
|
||||
PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS)
|
||||
PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS)
|
||||
PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS)
|
||||
PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS
|
||||
PasswordOption.FullyRandom -> phonemes = false
|
||||
PasswordOption.AtLeastOneSymbol -> {
|
||||
numCharacterCategories++
|
||||
pwgenFlags = pwgenFlags or SYMBOLS
|
||||
}
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
// The No* options are false, so the respective character category will be included.
|
||||
when (option) {
|
||||
PasswordOption.NoDigits, PasswordOption.NoUppercaseLetters, PasswordOption.NoLowercaseLetters -> {
|
||||
numCharacterCategories++
|
||||
}
|
||||
PasswordOption.NoAmbiguousCharacters,
|
||||
PasswordOption.FullyRandom,
|
||||
// Since AtLeastOneSymbol is not negated, it is counted in the if branch.
|
||||
PasswordOption.AtLeastOneSymbol -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isValidPassword(password: String, pwFlags: Int): Boolean {
|
||||
if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR })
|
||||
return false
|
||||
if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR })
|
||||
return false
|
||||
if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR })
|
||||
return false
|
||||
if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR })
|
||||
return false
|
||||
if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR })
|
||||
return false
|
||||
return true
|
||||
val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH)
|
||||
if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) {
|
||||
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error))
|
||||
}
|
||||
if (length < numCharacterCategories) {
|
||||
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error))
|
||||
}
|
||||
if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) {
|
||||
phonemes = false
|
||||
pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS)
|
||||
}
|
||||
// Experiments show that phonemes may require more than 1000 iterations to generate a valid
|
||||
// password if the length is not at least 6.
|
||||
if (length < 6) {
|
||||
phonemes = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a password using the preferences set by [setPrefs].
|
||||
*/
|
||||
@Throws(PasswordGeneratorException::class)
|
||||
fun generate(ctx: Context): String {
|
||||
val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
||||
var numCharacterCategories = 0
|
||||
|
||||
var phonemes = true
|
||||
var pwgenFlags = DIGITS or UPPERS or LOWERS
|
||||
|
||||
for (option in PasswordOption.values()) {
|
||||
if (prefs.getBoolean(option.key, false)) {
|
||||
when (option) {
|
||||
PasswordOption.NoDigits -> pwgenFlags = pwgenFlags.clearFlag(DIGITS)
|
||||
PasswordOption.NoUppercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(UPPERS)
|
||||
PasswordOption.NoLowercaseLetters -> pwgenFlags = pwgenFlags.clearFlag(LOWERS)
|
||||
PasswordOption.NoAmbiguousCharacters -> pwgenFlags = pwgenFlags or NO_AMBIGUOUS
|
||||
PasswordOption.FullyRandom -> phonemes = false
|
||||
PasswordOption.AtLeastOneSymbol -> {
|
||||
numCharacterCategories++
|
||||
pwgenFlags = pwgenFlags or SYMBOLS
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The No* options are false, so the respective character category will be included.
|
||||
when (option) {
|
||||
PasswordOption.NoDigits,
|
||||
PasswordOption.NoUppercaseLetters,
|
||||
PasswordOption.NoLowercaseLetters -> {
|
||||
numCharacterCategories++
|
||||
}
|
||||
PasswordOption.NoAmbiguousCharacters,
|
||||
PasswordOption.FullyRandom,
|
||||
// Since AtLeastOneSymbol is not negated, it is counted in the if branch.
|
||||
PasswordOption.AtLeastOneSymbol -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
var password: String?
|
||||
var iterations = 0
|
||||
do {
|
||||
if (iterations++ > 1000)
|
||||
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded))
|
||||
password =
|
||||
if (phonemes) {
|
||||
RandomPhonemesGenerator.generate(length, pwgenFlags)
|
||||
} else {
|
||||
RandomPasswordGenerator.generate(length, pwgenFlags)
|
||||
}
|
||||
} while (password == null)
|
||||
return password
|
||||
}
|
||||
|
||||
val length = prefs.getInt(PreferenceKeys.LENGTH, DEFAULT_LENGTH)
|
||||
if (pwgenFlags.clearFlag(NO_AMBIGUOUS) == 0) {
|
||||
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error))
|
||||
}
|
||||
if (length < numCharacterCategories) {
|
||||
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error))
|
||||
}
|
||||
if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) {
|
||||
phonemes = false
|
||||
pwgenFlags = pwgenFlags.clearFlag(NO_AMBIGUOUS)
|
||||
}
|
||||
// Experiments show that phonemes may require more than 1000 iterations to generate a valid
|
||||
// password if the length is not at least 6.
|
||||
if (length < 6) {
|
||||
phonemes = false
|
||||
}
|
||||
|
||||
var password: String?
|
||||
var iterations = 0
|
||||
do {
|
||||
if (iterations++ > 1000)
|
||||
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded))
|
||||
password = if (phonemes) {
|
||||
RandomPhonemesGenerator.generate(length, pwgenFlags)
|
||||
} else {
|
||||
RandomPasswordGenerator.generate(length, pwgenFlags)
|
||||
}
|
||||
} while (password == null)
|
||||
return password
|
||||
}
|
||||
|
||||
class PasswordGeneratorException(string: String) : Exception(string)
|
||||
class PasswordGeneratorException(string: String) : Exception(string)
|
||||
}
|
||||
|
|
|
@ -8,26 +8,24 @@ import java.security.SecureRandom
|
|||
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
/**
|
||||
* Returns a number between 0 (inclusive) and [exclusiveBound] (exclusive).
|
||||
*/
|
||||
/** Returns a number between 0 (inclusive) and [exclusiveBound](exclusive). */
|
||||
fun secureRandomNumber(exclusiveBound: Int) = secureRandom.nextInt(exclusiveBound)
|
||||
|
||||
/**
|
||||
* Returns `true` and `false` with probablity 50% each.
|
||||
*/
|
||||
/** Returns `true` and `false` with probablity 50% each. */
|
||||
fun secureRandomBoolean() = secureRandom.nextBoolean()
|
||||
|
||||
/**
|
||||
* Returns `true` with probability [percentTrue]% and `false` with probability
|
||||
* `(100 - [percentTrue])`%.
|
||||
* Returns `true` with probability [percentTrue]% and `false` with probability `(100 - [percentTrue]
|
||||
* )`%.
|
||||
*/
|
||||
fun secureRandomBiasedBoolean(percentTrue: Int): Boolean {
|
||||
require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" }
|
||||
require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" }
|
||||
return secureRandomNumber(100) < percentTrue
|
||||
require(1 <= percentTrue) { "Probability for returning `true` must be at least 1%" }
|
||||
require(percentTrue <= 99) { "Probability for returning `true` must be at most 99%" }
|
||||
return secureRandomNumber(100) < percentTrue
|
||||
}
|
||||
|
||||
fun <T> Array<T>.secureRandomElement() = this[secureRandomNumber(size)]
|
||||
|
||||
fun <T> List<T>.secureRandomElement() = this[secureRandomNumber(size)]
|
||||
|
||||
fun String.secureRandomCharacter() = this[secureRandomNumber(length)]
|
||||
|
|
|
@ -8,38 +8,39 @@ import dev.msfjarvis.aps.util.extensions.hasFlag
|
|||
|
||||
object RandomPasswordGenerator {
|
||||
|
||||
/**
|
||||
* Generates a random password of length [targetLength], taking the following flags in [pwFlags]
|
||||
* into account, or fails to do so and returns null:
|
||||
*
|
||||
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not
|
||||
* set, the password will not contain any digits.
|
||||
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase
|
||||
* letter; if not set, the password will not contain any uppercase letters.
|
||||
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase
|
||||
* letter; if not set, the password will not contain any lowercase letters.
|
||||
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
|
||||
* set, the password will not contain any symbols.
|
||||
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
|
||||
* characters.
|
||||
*/
|
||||
fun generate(targetLength: Int, pwFlags: Int): String? {
|
||||
val bank = listOfNotNull(
|
||||
PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS },
|
||||
PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS },
|
||||
PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS },
|
||||
PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS },
|
||||
).joinToString("")
|
||||
/**
|
||||
* Generates a random password of length [targetLength], taking the following flags in [pwFlags]
|
||||
* into account, or fails to do so and returns null:
|
||||
*
|
||||
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set,
|
||||
* the password will not contain any digits.
|
||||
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter;
|
||||
* if not set, the password will not contain any uppercase letters.
|
||||
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter;
|
||||
* if not set, the password will not contain any lowercase letters.
|
||||
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
|
||||
* set, the password will not contain any symbols.
|
||||
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
|
||||
* characters.
|
||||
*/
|
||||
fun generate(targetLength: Int, pwFlags: Int): String? {
|
||||
val bank =
|
||||
listOfNotNull(
|
||||
PasswordGenerator.DIGITS_STR.takeIf { pwFlags hasFlag PasswordGenerator.DIGITS },
|
||||
PasswordGenerator.UPPERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.UPPERS },
|
||||
PasswordGenerator.LOWERS_STR.takeIf { pwFlags hasFlag PasswordGenerator.LOWERS },
|
||||
PasswordGenerator.SYMBOLS_STR.takeIf { pwFlags hasFlag PasswordGenerator.SYMBOLS },
|
||||
)
|
||||
.joinToString("")
|
||||
|
||||
var password = ""
|
||||
while (password.length < targetLength) {
|
||||
val candidate = bank.secureRandomCharacter()
|
||||
if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
|
||||
candidate in PasswordGenerator.AMBIGUOUS_STR) {
|
||||
continue
|
||||
}
|
||||
password += candidate
|
||||
}
|
||||
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
|
||||
var password = ""
|
||||
while (password.length < targetLength) {
|
||||
val candidate = bank.secureRandomCharacter()
|
||||
if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate in PasswordGenerator.AMBIGUOUS_STR) {
|
||||
continue
|
||||
}
|
||||
password += candidate
|
||||
}
|
||||
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,161 +9,161 @@ import java.util.Locale
|
|||
|
||||
object RandomPhonemesGenerator {
|
||||
|
||||
private const val CONSONANT = 0x0001
|
||||
private const val VOWEL = 0x0002
|
||||
private const val DIPHTHONG = 0x0004
|
||||
private const val NOT_FIRST = 0x0008
|
||||
private const val CONSONANT = 0x0001
|
||||
private const val VOWEL = 0x0002
|
||||
private const val DIPHTHONG = 0x0004
|
||||
private const val NOT_FIRST = 0x0008
|
||||
|
||||
private val elements = arrayOf(
|
||||
Element("a", VOWEL),
|
||||
Element("ae", VOWEL or DIPHTHONG),
|
||||
Element("ah", VOWEL or DIPHTHONG),
|
||||
Element("ai", VOWEL or DIPHTHONG),
|
||||
Element("b", CONSONANT),
|
||||
Element("c", CONSONANT),
|
||||
Element("ch", CONSONANT or DIPHTHONG),
|
||||
Element("d", CONSONANT),
|
||||
Element("e", VOWEL),
|
||||
Element("ee", VOWEL or DIPHTHONG),
|
||||
Element("ei", VOWEL or DIPHTHONG),
|
||||
Element("f", CONSONANT),
|
||||
Element("g", CONSONANT),
|
||||
Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST),
|
||||
Element("h", CONSONANT),
|
||||
Element("i", VOWEL),
|
||||
Element("ie", VOWEL or DIPHTHONG),
|
||||
Element("j", CONSONANT),
|
||||
Element("k", CONSONANT),
|
||||
Element("l", CONSONANT),
|
||||
Element("m", CONSONANT),
|
||||
Element("n", CONSONANT),
|
||||
Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST),
|
||||
Element("o", VOWEL),
|
||||
Element("oh", VOWEL or DIPHTHONG),
|
||||
Element("oo", VOWEL or DIPHTHONG),
|
||||
Element("p", CONSONANT),
|
||||
Element("ph", CONSONANT or DIPHTHONG),
|
||||
Element("qu", CONSONANT or DIPHTHONG),
|
||||
Element("r", CONSONANT),
|
||||
Element("s", CONSONANT),
|
||||
Element("sh", CONSONANT or DIPHTHONG),
|
||||
Element("t", CONSONANT),
|
||||
Element("th", CONSONANT or DIPHTHONG),
|
||||
Element("u", VOWEL),
|
||||
Element("v", CONSONANT),
|
||||
Element("w", CONSONANT),
|
||||
Element("x", CONSONANT),
|
||||
Element("y", CONSONANT),
|
||||
Element("z", CONSONANT)
|
||||
private val elements =
|
||||
arrayOf(
|
||||
Element("a", VOWEL),
|
||||
Element("ae", VOWEL or DIPHTHONG),
|
||||
Element("ah", VOWEL or DIPHTHONG),
|
||||
Element("ai", VOWEL or DIPHTHONG),
|
||||
Element("b", CONSONANT),
|
||||
Element("c", CONSONANT),
|
||||
Element("ch", CONSONANT or DIPHTHONG),
|
||||
Element("d", CONSONANT),
|
||||
Element("e", VOWEL),
|
||||
Element("ee", VOWEL or DIPHTHONG),
|
||||
Element("ei", VOWEL or DIPHTHONG),
|
||||
Element("f", CONSONANT),
|
||||
Element("g", CONSONANT),
|
||||
Element("gh", CONSONANT or DIPHTHONG or NOT_FIRST),
|
||||
Element("h", CONSONANT),
|
||||
Element("i", VOWEL),
|
||||
Element("ie", VOWEL or DIPHTHONG),
|
||||
Element("j", CONSONANT),
|
||||
Element("k", CONSONANT),
|
||||
Element("l", CONSONANT),
|
||||
Element("m", CONSONANT),
|
||||
Element("n", CONSONANT),
|
||||
Element("ng", CONSONANT or DIPHTHONG or NOT_FIRST),
|
||||
Element("o", VOWEL),
|
||||
Element("oh", VOWEL or DIPHTHONG),
|
||||
Element("oo", VOWEL or DIPHTHONG),
|
||||
Element("p", CONSONANT),
|
||||
Element("ph", CONSONANT or DIPHTHONG),
|
||||
Element("qu", CONSONANT or DIPHTHONG),
|
||||
Element("r", CONSONANT),
|
||||
Element("s", CONSONANT),
|
||||
Element("sh", CONSONANT or DIPHTHONG),
|
||||
Element("t", CONSONANT),
|
||||
Element("th", CONSONANT or DIPHTHONG),
|
||||
Element("u", VOWEL),
|
||||
Element("v", CONSONANT),
|
||||
Element("w", CONSONANT),
|
||||
Element("x", CONSONANT),
|
||||
Element("y", CONSONANT),
|
||||
Element("z", CONSONANT)
|
||||
)
|
||||
|
||||
private class Element(str: String, val flags: Int) {
|
||||
private class Element(str: String, val flags: Int) {
|
||||
|
||||
val upperCase = str.toUpperCase(Locale.ROOT)
|
||||
val lowerCase = str.toLowerCase(Locale.ROOT)
|
||||
val length = str.length
|
||||
val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR }
|
||||
}
|
||||
val upperCase = str.toUpperCase(Locale.ROOT)
|
||||
val lowerCase = str.toLowerCase(Locale.ROOT)
|
||||
val length = str.length
|
||||
val isAmbiguous = str.any { it in PasswordGenerator.AMBIGUOUS_STR }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random human-readable password of length [targetLength], taking the following
|
||||
* flags in [pwFlags] into account, or fails to do so and returns null:
|
||||
*
|
||||
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not
|
||||
* set, the password will not contain any digits.
|
||||
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase
|
||||
* letter; if not set, the password will not contain any uppercase letters.
|
||||
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase
|
||||
* letter; if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any
|
||||
* lowercase characters; if both are not set, an exception is thrown.
|
||||
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
|
||||
* set, the password will not contain any symbols.
|
||||
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
|
||||
* characters.
|
||||
*/
|
||||
fun generate(targetLength: Int, pwFlags: Int): String? {
|
||||
require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS)
|
||||
/**
|
||||
* Generates a random human-readable password of length [targetLength], taking the following flags
|
||||
* in [pwFlags] into account, or fails to do so and returns null:
|
||||
*
|
||||
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set,
|
||||
* the password will not contain any digits.
|
||||
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter;
|
||||
* if not set, the password will not contain any uppercase letters.
|
||||
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter;
|
||||
* if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any lowercase
|
||||
* characters; if both are not set, an exception is thrown.
|
||||
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
|
||||
* set, the password will not contain any symbols.
|
||||
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
|
||||
* characters.
|
||||
*/
|
||||
fun generate(targetLength: Int, pwFlags: Int): String? {
|
||||
require(pwFlags hasFlag PasswordGenerator.UPPERS || pwFlags hasFlag PasswordGenerator.LOWERS)
|
||||
|
||||
var password = ""
|
||||
var password = ""
|
||||
|
||||
var isStartOfPart = true
|
||||
var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
|
||||
var previousFlags = 0
|
||||
var isStartOfPart = true
|
||||
var nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
|
||||
var previousFlags = 0
|
||||
|
||||
while (password.length < targetLength) {
|
||||
// First part: Add a single letter or pronounceable pair of letters in varying case.
|
||||
while (password.length < targetLength) {
|
||||
// First part: Add a single letter or pronounceable pair of letters in varying case.
|
||||
|
||||
val candidate = elements.secureRandomElement()
|
||||
val candidate = elements.secureRandomElement()
|
||||
|
||||
// Reroll if the candidate does not fulfill the current requirements.
|
||||
if (!candidate.flags.hasFlag(nextBasicType) ||
|
||||
(isStartOfPart && candidate.flags hasFlag NOT_FIRST) ||
|
||||
// Don't let a diphthong that starts with a vowel follow a vowel.
|
||||
(previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) ||
|
||||
// Don't add multi-character candidates if we would go over the targetLength.
|
||||
(password.length + candidate.length > targetLength) ||
|
||||
(pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)) {
|
||||
continue
|
||||
}
|
||||
// Reroll if the candidate does not fulfill the current requirements.
|
||||
if (!candidate.flags.hasFlag(nextBasicType) ||
|
||||
(isStartOfPart && candidate.flags hasFlag NOT_FIRST) ||
|
||||
// Don't let a diphthong that starts with a vowel follow a vowel.
|
||||
(previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) ||
|
||||
// Don't add multi-character candidates if we would go over the targetLength.
|
||||
(password.length + candidate.length > targetLength) ||
|
||||
(pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// At this point the candidate could be appended to the password, but we still have
|
||||
// to determine the case. If no upper case characters are required, we don't add
|
||||
// any.
|
||||
val useUpperIfBothCasesAllowed =
|
||||
(isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20)
|
||||
password += if (pwFlags hasFlag PasswordGenerator.UPPERS &&
|
||||
(!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)) {
|
||||
candidate.upperCase
|
||||
} else {
|
||||
candidate.lowerCase
|
||||
}
|
||||
|
||||
// We ensured above that we will not go above the target length.
|
||||
check(password.length <= targetLength)
|
||||
if (password.length == targetLength)
|
||||
break
|
||||
|
||||
// Second part: Add digits and symbols with a certain probability (if requested) if
|
||||
// they would not directly follow the first character in a pronounceable part.
|
||||
|
||||
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS &&
|
||||
secureRandomBiasedBoolean(30)) {
|
||||
var randomDigit: Char
|
||||
do {
|
||||
randomDigit = secureRandomNumber(10).toString(10).first()
|
||||
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
|
||||
randomDigit in PasswordGenerator.AMBIGUOUS_STR)
|
||||
|
||||
password += randomDigit
|
||||
// Begin a new pronounceable part after every digit.
|
||||
isStartOfPart = true
|
||||
nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
|
||||
previousFlags = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS &&
|
||||
secureRandomBiasedBoolean(20)) {
|
||||
var randomSymbol: Char
|
||||
do {
|
||||
randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter()
|
||||
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
|
||||
randomSymbol in PasswordGenerator.AMBIGUOUS_STR)
|
||||
password += randomSymbol
|
||||
// Continue the password generation as if nothing was added.
|
||||
}
|
||||
|
||||
// Third part: Determine the basic type of the next character depending on the letter
|
||||
// we just added.
|
||||
nextBasicType = when {
|
||||
candidate.flags.hasFlag(CONSONANT) -> VOWEL
|
||||
previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) ||
|
||||
secureRandomBiasedBoolean(60) -> CONSONANT
|
||||
else -> VOWEL
|
||||
}
|
||||
previousFlags = candidate.flags
|
||||
isStartOfPart = false
|
||||
// At this point the candidate could be appended to the password, but we still have
|
||||
// to determine the case. If no upper case characters are required, we don't add
|
||||
// any.
|
||||
val useUpperIfBothCasesAllowed =
|
||||
(isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20)
|
||||
password +=
|
||||
if (pwFlags hasFlag PasswordGenerator.UPPERS &&
|
||||
(!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)
|
||||
) {
|
||||
candidate.upperCase
|
||||
} else {
|
||||
candidate.lowerCase
|
||||
}
|
||||
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
|
||||
|
||||
// We ensured above that we will not go above the target length.
|
||||
check(password.length <= targetLength)
|
||||
if (password.length == targetLength) break
|
||||
|
||||
// Second part: Add digits and symbols with a certain probability (if requested) if
|
||||
// they would not directly follow the first character in a pronounceable part.
|
||||
|
||||
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS && secureRandomBiasedBoolean(30)) {
|
||||
var randomDigit: Char
|
||||
do {
|
||||
randomDigit = secureRandomNumber(10).toString(10).first()
|
||||
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && randomDigit in PasswordGenerator.AMBIGUOUS_STR)
|
||||
|
||||
password += randomDigit
|
||||
// Begin a new pronounceable part after every digit.
|
||||
isStartOfPart = true
|
||||
nextBasicType = if (secureRandomBoolean()) VOWEL else CONSONANT
|
||||
previousFlags = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS && secureRandomBiasedBoolean(20)) {
|
||||
var randomSymbol: Char
|
||||
do {
|
||||
randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter()
|
||||
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && randomSymbol in PasswordGenerator.AMBIGUOUS_STR)
|
||||
password += randomSymbol
|
||||
// Continue the password generation as if nothing was added.
|
||||
}
|
||||
|
||||
// Third part: Determine the basic type of the next character depending on the letter
|
||||
// we just added.
|
||||
nextBasicType =
|
||||
when {
|
||||
candidate.flags.hasFlag(CONSONANT) -> VOWEL
|
||||
previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) || secureRandomBiasedBoolean(60) ->
|
||||
CONSONANT
|
||||
else -> VOWEL
|
||||
}
|
||||
previousFlags = candidate.flags
|
||||
isStartOfPart = false
|
||||
}
|
||||
return password.takeIf { PasswordGenerator.isValidPassword(it, pwFlags) }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,5 +5,9 @@
|
|||
package dev.msfjarvis.aps.util.pwgenxkpwd
|
||||
|
||||
enum class CapsType {
|
||||
lowercase, UPPERCASE, TitleCase, Sentence, As_iS
|
||||
lowercase,
|
||||
UPPERCASE,
|
||||
TitleCase,
|
||||
Sentence,
|
||||
As_iS
|
||||
}
|
||||
|
|
|
@ -16,127 +16,120 @@ import java.util.Locale
|
|||
|
||||
class PasswordBuilder(ctx: Context) {
|
||||
|
||||
private var numSymbols = 0
|
||||
private var isAppendSymbolsSeparator = false
|
||||
private var context = ctx
|
||||
private var numWords = 3
|
||||
private var maxWordLength = 9
|
||||
private var minWordLength = 5
|
||||
private var separator = "."
|
||||
private var capsType = CapsType.Sentence
|
||||
private var prependDigits = 0
|
||||
private var numDigits = 0
|
||||
private var isPrependWithSeparator = false
|
||||
private var isAppendNumberSeparator = false
|
||||
private var numSymbols = 0
|
||||
private var isAppendSymbolsSeparator = false
|
||||
private var context = ctx
|
||||
private var numWords = 3
|
||||
private var maxWordLength = 9
|
||||
private var minWordLength = 5
|
||||
private var separator = "."
|
||||
private var capsType = CapsType.Sentence
|
||||
private var prependDigits = 0
|
||||
private var numDigits = 0
|
||||
private var isPrependWithSeparator = false
|
||||
private var isAppendNumberSeparator = false
|
||||
|
||||
fun setNumberOfWords(amount: Int) = apply {
|
||||
numWords = amount
|
||||
fun setNumberOfWords(amount: Int) = apply { numWords = amount }
|
||||
|
||||
fun setMinimumWordLength(min: Int) = apply { minWordLength = min }
|
||||
|
||||
fun setMaximumWordLength(max: Int) = apply { maxWordLength = max }
|
||||
|
||||
fun setSeparator(separator: String) = apply { this.separator = separator }
|
||||
|
||||
fun setCapitalization(capitalizationScheme: CapsType) = apply { capsType = capitalizationScheme }
|
||||
|
||||
@JvmOverloads
|
||||
fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply {
|
||||
prependDigits = numDigits
|
||||
isPrependWithSeparator = addSeparator
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply {
|
||||
this.numDigits = numDigits
|
||||
isAppendNumberSeparator = addSeparator
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun appendSymbols(numSymbols: Int, addSeparator: Boolean = false) = apply {
|
||||
this.numSymbols = numSymbols
|
||||
isAppendSymbolsSeparator = addSeparator
|
||||
}
|
||||
|
||||
private fun generateRandomNumberSequence(totalNumbers: Int): String {
|
||||
val numbers = StringBuilder(totalNumbers)
|
||||
for (i in 0 until totalNumbers) {
|
||||
numbers.append(secureRandomNumber(10))
|
||||
}
|
||||
return numbers.toString()
|
||||
}
|
||||
|
||||
fun setMinimumWordLength(min: Int) = apply {
|
||||
minWordLength = min
|
||||
private fun generateRandomSymbolSequence(numSymbols: Int): String {
|
||||
val numbers = StringBuilder(numSymbols)
|
||||
for (i in 0 until numSymbols) {
|
||||
numbers.append(SYMBOLS.secureRandomCharacter())
|
||||
}
|
||||
return numbers.toString()
|
||||
}
|
||||
|
||||
fun setMaximumWordLength(max: Int) = apply {
|
||||
maxWordLength = max
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun create(): Result<String, Throwable> {
|
||||
val wordBank = mutableListOf<String>()
|
||||
val password = StringBuilder()
|
||||
|
||||
if (prependDigits != 0) {
|
||||
password.append(generateRandomNumberSequence(prependDigits))
|
||||
if (isPrependWithSeparator) {
|
||||
password.append(separator)
|
||||
}
|
||||
}
|
||||
return runCatching {
|
||||
val dictionary = XkpwdDictionary(context)
|
||||
val words = dictionary.words
|
||||
for (wordLength in minWordLength..maxWordLength) {
|
||||
wordBank.addAll(words[wordLength] ?: emptyList())
|
||||
}
|
||||
|
||||
fun setSeparator(separator: String) = apply {
|
||||
this.separator = separator
|
||||
}
|
||||
if (wordBank.size == 0) {
|
||||
throw PasswordGeneratorException(
|
||||
context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength)
|
||||
)
|
||||
}
|
||||
|
||||
fun setCapitalization(capitalizationScheme: CapsType) = apply {
|
||||
capsType = capitalizationScheme
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply {
|
||||
prependDigits = numDigits
|
||||
isPrependWithSeparator = addSeparator
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun appendNumbers(numDigits: Int, addSeparator: Boolean = false) = apply {
|
||||
this.numDigits = numDigits
|
||||
isAppendNumberSeparator = addSeparator
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun appendSymbols(numSymbols: Int, addSeparator: Boolean = false) = apply {
|
||||
this.numSymbols = numSymbols
|
||||
isAppendSymbolsSeparator = addSeparator
|
||||
}
|
||||
|
||||
private fun generateRandomNumberSequence(totalNumbers: Int): String {
|
||||
val numbers = StringBuilder(totalNumbers)
|
||||
for (i in 0 until totalNumbers) {
|
||||
numbers.append(secureRandomNumber(10))
|
||||
for (i in 0 until numWords) {
|
||||
val candidate = wordBank.secureRandomElement()
|
||||
val s =
|
||||
when (capsType) {
|
||||
CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault())
|
||||
CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate
|
||||
CapsType.TitleCase -> candidate.capitalize(Locale.getDefault())
|
||||
CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault())
|
||||
CapsType.As_iS -> candidate
|
||||
}
|
||||
password.append(s)
|
||||
if (i + 1 < numWords) {
|
||||
password.append(separator)
|
||||
}
|
||||
return numbers.toString()
|
||||
}
|
||||
|
||||
private fun generateRandomSymbolSequence(numSymbols: Int): String {
|
||||
val numbers = StringBuilder(numSymbols)
|
||||
for (i in 0 until numSymbols) {
|
||||
numbers.append(SYMBOLS.secureRandomCharacter())
|
||||
}
|
||||
if (numDigits != 0) {
|
||||
if (isAppendNumberSeparator) {
|
||||
password.append(separator)
|
||||
}
|
||||
return numbers.toString()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun create(): Result<String, Throwable> {
|
||||
val wordBank = mutableListOf<String>()
|
||||
val password = StringBuilder()
|
||||
|
||||
if (prependDigits != 0) {
|
||||
password.append(generateRandomNumberSequence(prependDigits))
|
||||
if (isPrependWithSeparator) {
|
||||
password.append(separator)
|
||||
}
|
||||
}
|
||||
return runCatching {
|
||||
val dictionary = XkpwdDictionary(context)
|
||||
val words = dictionary.words
|
||||
for (wordLength in minWordLength..maxWordLength) {
|
||||
wordBank.addAll(words[wordLength] ?: emptyList())
|
||||
}
|
||||
|
||||
if (wordBank.size == 0) {
|
||||
throw PasswordGeneratorException(context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength))
|
||||
}
|
||||
|
||||
for (i in 0 until numWords) {
|
||||
val candidate = wordBank.secureRandomElement()
|
||||
val s = when (capsType) {
|
||||
CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault())
|
||||
CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate
|
||||
CapsType.TitleCase -> candidate.capitalize(Locale.getDefault())
|
||||
CapsType.lowercase -> candidate.toLowerCase(Locale.getDefault())
|
||||
CapsType.As_iS -> candidate
|
||||
}
|
||||
password.append(s)
|
||||
if (i + 1 < numWords) {
|
||||
password.append(separator)
|
||||
}
|
||||
}
|
||||
if (numDigits != 0) {
|
||||
if (isAppendNumberSeparator) {
|
||||
password.append(separator)
|
||||
}
|
||||
password.append(generateRandomNumberSequence(numDigits))
|
||||
}
|
||||
if (numSymbols != 0) {
|
||||
if (isAppendSymbolsSeparator) {
|
||||
password.append(separator)
|
||||
}
|
||||
password.append(generateRandomSymbolSequence(numSymbols))
|
||||
}
|
||||
password.toString()
|
||||
password.append(generateRandomNumberSequence(numDigits))
|
||||
}
|
||||
if (numSymbols != 0) {
|
||||
if (isAppendSymbolsSeparator) {
|
||||
password.append(separator)
|
||||
}
|
||||
password.append(generateRandomSymbolSequence(numSymbols))
|
||||
}
|
||||
password.toString()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#"
|
||||
}
|
||||
private const val SYMBOLS = "!@\$%^&*-_+=:|~?/.;#"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,28 +13,28 @@ import java.io.File
|
|||
|
||||
class XkpwdDictionary(context: Context) {
|
||||
|
||||
val words: Map<Int, List<String>>
|
||||
val words: Map<Int, List<String>>
|
||||
|
||||
init {
|
||||
val prefs = context.sharedPrefs
|
||||
val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: ""
|
||||
val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE)
|
||||
init {
|
||||
val prefs = context.sharedPrefs
|
||||
val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: ""
|
||||
val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE)
|
||||
|
||||
val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) &&
|
||||
uri.isNotEmpty() && customDictFile.canRead()) {
|
||||
customDictFile.readLines()
|
||||
} else {
|
||||
context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines()
|
||||
}
|
||||
val lines =
|
||||
if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) &&
|
||||
uri.isNotEmpty() &&
|
||||
customDictFile.canRead()
|
||||
) {
|
||||
customDictFile.readLines()
|
||||
} else {
|
||||
context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines()
|
||||
}
|
||||
|
||||
words = lines.asSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.contains(' ') }
|
||||
.groupBy { it.length }
|
||||
}
|
||||
words = lines.asSequence().map { it.trim() }.filter { it.isNotEmpty() && !it.contains(' ') }.groupBy { it.length }
|
||||
}
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt"
|
||||
}
|
||||
const val XKPWD_CUSTOM_DICT_FILE = "custom_dict.txt"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,155 +32,150 @@ import kotlinx.coroutines.withContext
|
|||
|
||||
class ClipboardService : Service() {
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent != null) {
|
||||
when (intent.action) {
|
||||
ACTION_CLEAR -> {
|
||||
clearClipboard()
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent != null) {
|
||||
when (intent.action) {
|
||||
ACTION_CLEAR -> {
|
||||
clearClipboard()
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
ACTION_START -> {
|
||||
val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45)
|
||||
|
||||
ACTION_START -> {
|
||||
val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45)
|
||||
if (time == 0) {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
if (time == 0) {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
createNotification(time)
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
startTimer(time)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
clearClipboard()
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
createNotification(time)
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { startTimer(time) }
|
||||
withContext(Dispatchers.Main) {
|
||||
clearClipboard()
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun clearClipboard() {
|
||||
val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false)
|
||||
val clipboard = clipboard
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
if (clipboard != null) {
|
||||
scope.launch {
|
||||
d { "Clearing the clipboard" }
|
||||
val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (deepClear) {
|
||||
withContext(Dispatchers.IO) {
|
||||
repeat(CLIPBOARD_CLEAR_COUNT) {
|
||||
val count = (it * 500).toString()
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(count, count))
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun clearClipboard() {
|
||||
val deepClear = sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false)
|
||||
val clipboard = clipboard
|
||||
|
||||
if (clipboard != null) {
|
||||
scope.launch {
|
||||
d { "Clearing the clipboard" }
|
||||
val clip = ClipData.newPlainText("pgp_handler_result_pm", "")
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (deepClear) {
|
||||
withContext(Dispatchers.IO) {
|
||||
repeat(CLIPBOARD_CLEAR_COUNT) {
|
||||
val count = (it * 500).toString()
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(count, count))
|
||||
}
|
||||
} else {
|
||||
d { "Cannot get clipboard manager service" }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
d { "Cannot get clipboard manager service" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startTimer(showTime: Int) {
|
||||
var current = 0
|
||||
while (scope.isActive && current < showTime) {
|
||||
// Block for 1s or until cancel is signalled
|
||||
current++
|
||||
delay(1000)
|
||||
}
|
||||
private suspend fun startTimer(showTime: Int) {
|
||||
var current = 0
|
||||
while (scope.isActive && current < showTime) {
|
||||
// Block for 1s or until cancel is signalled
|
||||
current++
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(clearTime: Int) {
|
||||
val clearTimeMs = clearTime * 1000L
|
||||
val clearIntent = Intent(this, ClipboardService::class.java).apply {
|
||||
action = ACTION_CLEAR
|
||||
}
|
||||
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
} else {
|
||||
PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
val notification = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
|
||||
createNotificationApi23(pendingIntent)
|
||||
} else {
|
||||
createNotificationApi24(pendingIntent, clearTimeMs)
|
||||
}
|
||||
private fun createNotification(clearTime: Int) {
|
||||
val clearTimeMs = clearTime * 1000L
|
||||
val clearIntent = Intent(this, ClipboardService::class.java).apply { action = ACTION_CLEAR }
|
||||
val pendingIntent =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
} else {
|
||||
PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
val notification =
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
|
||||
createNotificationApi23(pendingIntent)
|
||||
} else {
|
||||
createNotificationApi24(pendingIntent, clearTimeMs)
|
||||
}
|
||||
|
||||
createNotificationChannel()
|
||||
startForeground(1, notification)
|
||||
createNotificationChannel()
|
||||
startForeground(1, notification)
|
||||
}
|
||||
|
||||
private fun createNotificationApi23(pendingIntent: PendingIntent): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(R.string.tap_clear_clipboard))
|
||||
.setSmallIcon(R.drawable.ic_action_secure_24dp)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setUsesChronometer(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(R.string.tap_clear_clipboard))
|
||||
.setSmallIcon(R.drawable.ic_action_secure_24dp)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setUsesChronometer(true)
|
||||
.setChronometerCountDown(true)
|
||||
.setShowWhen(true)
|
||||
.setWhen(System.currentTimeMillis() + clearTimeMs)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val serviceChannel =
|
||||
NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW)
|
||||
val manager = getSystemService<NotificationManager>()
|
||||
if (manager != null) {
|
||||
manager.createNotificationChannel(serviceChannel)
|
||||
} else {
|
||||
d { "Failed to create notification channel" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationApi23(pendingIntent: PendingIntent): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(R.string.tap_clear_clipboard))
|
||||
.setSmallIcon(R.drawable.ic_action_secure_24dp)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setUsesChronometer(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
companion object {
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(R.string.tap_clear_clipboard))
|
||||
.setSmallIcon(R.drawable.ic_action_secure_24dp)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setUsesChronometer(true)
|
||||
.setChronometerCountDown(true)
|
||||
.setShowWhen(true)
|
||||
.setWhen(System.currentTimeMillis() + clearTimeMs)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val serviceChannel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.app_name),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
val manager = getSystemService<NotificationManager>()
|
||||
if (manager != null) {
|
||||
manager.createNotificationChannel(serviceChannel)
|
||||
} else {
|
||||
d { "Failed to create notification channel" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER"
|
||||
const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME"
|
||||
private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
|
||||
private const val CHANNEL_ID = "NotificationService"
|
||||
// Newest Samsung phones now feature a history of up to 30 items. To err on the side of caution,
|
||||
// push 35 fake ones.
|
||||
private const val CLIPBOARD_CLEAR_COUNT = 35
|
||||
}
|
||||
const val ACTION_START = "ACTION_START_CLIPBOARD_TIMER"
|
||||
const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME"
|
||||
private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
|
||||
private const val CHANNEL_ID = "NotificationService"
|
||||
// Newest Samsung phones now feature a history of up to 30 items. To err on the side of
|
||||
// caution,
|
||||
// push 35 fake ones.
|
||||
private const val CLIPBOARD_CLEAR_COUNT = 35
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,108 +38,121 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
|||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
class OreoAutofillService : AutofillService() {
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
// TODO: Provide a user-configurable denylist
|
||||
private val DENYLISTED_PACKAGES = listOf(
|
||||
BuildConfig.APPLICATION_ID,
|
||||
"android",
|
||||
"com.android.settings",
|
||||
"com.android.settings.intelligence",
|
||||
"com.android.systemui",
|
||||
"com.oneplus.applocker",
|
||||
"org.sufficientlysecure.keychain",
|
||||
)
|
||||
// TODO: Provide a user-configurable denylist
|
||||
private val DENYLISTED_PACKAGES =
|
||||
listOf(
|
||||
BuildConfig.APPLICATION_ID,
|
||||
"android",
|
||||
"com.android.settings",
|
||||
"com.android.settings.intelligence",
|
||||
"com.android.systemui",
|
||||
"com.oneplus.applocker",
|
||||
"org.sufficientlysecure.keychain",
|
||||
)
|
||||
|
||||
private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L
|
||||
}
|
||||
private const val DISABLE_AUTOFILL_DURATION_MS = 1000 * 60 * 60 * 24L
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
cachePublicSuffixList(applicationContext)
|
||||
}
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
cachePublicSuffixList(applicationContext)
|
||||
}
|
||||
|
||||
override fun onFillRequest(
|
||||
request: FillRequest,
|
||||
cancellationSignal: CancellationSignal,
|
||||
callback: FillCallback
|
||||
) {
|
||||
val structure = request.fillContexts.lastOrNull()?.structure ?: run {
|
||||
callback.onSuccess(null)
|
||||
return
|
||||
}
|
||||
if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
callback.onSuccess(FillResponse.Builder().run {
|
||||
disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
|
||||
build()
|
||||
})
|
||||
} else {
|
||||
callback.onSuccess(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
val formToFill = FillableForm.parseAssistStructure(
|
||||
this, structure,
|
||||
isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST,
|
||||
getCustomSuffixes(),
|
||||
) ?: run {
|
||||
d { "Form cannot be filled" }
|
||||
callback.onSuccess(null)
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback)
|
||||
} else {
|
||||
AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
|
||||
// SaveCallback's behavior and feature set differs based on both target and device SDK, so
|
||||
// we replace it with a wrapper that works the same in all situations.
|
||||
@Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback)
|
||||
val structure = request.fillContexts.lastOrNull()?.structure ?: run {
|
||||
callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported))
|
||||
return
|
||||
}
|
||||
val clientState = request.clientState ?: run {
|
||||
e { "Received save request without client state" }
|
||||
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
||||
return
|
||||
}
|
||||
val scenario = AutofillScenario.fromClientState(clientState)?.recoverNodes(structure)
|
||||
?: run {
|
||||
e { "Failed to recover client state or nodes from client state" }
|
||||
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
||||
return
|
||||
}
|
||||
val formOrigin = FormOrigin.fromBundle(clientState) ?: run {
|
||||
e { "Failed to recover form origin from client state" }
|
||||
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
||||
return
|
||||
}
|
||||
|
||||
val username = scenario.usernameValue
|
||||
val password = scenario.passwordValue ?: run {
|
||||
callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match))
|
||||
return
|
||||
override fun onFillRequest(request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback) {
|
||||
val structure =
|
||||
request.fillContexts.lastOrNull()?.structure
|
||||
?: run {
|
||||
callback.onSuccess(null)
|
||||
return
|
||||
}
|
||||
if (structure.activityComponent.packageName in DENYLISTED_PACKAGES) {
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
callback.onSuccess(
|
||||
AutofillSaveActivity.makeSaveIntentSender(
|
||||
this,
|
||||
credentials = Credentials(username, password, null),
|
||||
formOrigin = formOrigin
|
||||
)
|
||||
FillResponse.Builder().run {
|
||||
disableAutofill(DISABLE_AUTOFILL_DURATION_MS)
|
||||
build()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
callback.onSuccess(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
val formToFill =
|
||||
FillableForm.parseAssistStructure(
|
||||
this,
|
||||
structure,
|
||||
isManualRequest = request.flags hasFlag FillRequest.FLAG_MANUAL_REQUEST,
|
||||
getCustomSuffixes(),
|
||||
)
|
||||
?: run {
|
||||
d { "Form cannot be filled" }
|
||||
callback.onSuccess(null)
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback)
|
||||
} else {
|
||||
AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) {
|
||||
// SaveCallback's behavior and feature set differs based on both target and device SDK, so
|
||||
// we replace it with a wrapper that works the same in all situations.
|
||||
@Suppress("NAME_SHADOWING") val callback = FixedSaveCallback(this, callback)
|
||||
val structure =
|
||||
request.fillContexts.lastOrNull()?.structure
|
||||
?: run {
|
||||
callback.onFailure(getString(R.string.oreo_autofill_save_app_not_supported))
|
||||
return
|
||||
}
|
||||
val clientState =
|
||||
request.clientState
|
||||
?: run {
|
||||
e { "Received save request without client state" }
|
||||
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
||||
return
|
||||
}
|
||||
val scenario =
|
||||
AutofillScenario.fromClientState(clientState)?.recoverNodes(structure)
|
||||
?: run {
|
||||
e { "Failed to recover client state or nodes from client state" }
|
||||
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
||||
return
|
||||
}
|
||||
val formOrigin =
|
||||
FormOrigin.fromBundle(clientState)
|
||||
?: run {
|
||||
e { "Failed to recover form origin from client state" }
|
||||
callback.onFailure(getString(R.string.oreo_autofill_save_internal_error))
|
||||
return
|
||||
}
|
||||
|
||||
val username = scenario.usernameValue
|
||||
val password =
|
||||
scenario.passwordValue
|
||||
?: run {
|
||||
callback.onFailure(getString(R.string.oreo_autofill_save_passwords_dont_match))
|
||||
return
|
||||
}
|
||||
callback.onSuccess(
|
||||
AutofillSaveActivity.makeSaveIntentSender(
|
||||
this,
|
||||
credentials = Credentials(username, password, null),
|
||||
formOrigin = formOrigin
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.getDefaultUsername() = sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DEFAULT_USERNAME)
|
||||
|
||||
fun Context.getCustomSuffixes(): Sequence<String> {
|
||||
return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
|
||||
?.splitToSequence('\n')
|
||||
?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
|
||||
?: emptySequence()
|
||||
return sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)?.splitToSequence('\n')?.filter {
|
||||
it.isNotBlank() && it.first() != '.' && it.last() != '.'
|
||||
}
|
||||
?: emptySequence()
|
||||
}
|
||||
|
|
|
@ -25,134 +25,131 @@ import java.util.TimeZone
|
|||
|
||||
class PasswordExportService : Service() {
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent != null) {
|
||||
when (intent.action) {
|
||||
ACTION_EXPORT_PASSWORD -> {
|
||||
val uri = intent.getParcelableExtra<Uri>("uri")
|
||||
if (uri != null) {
|
||||
val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent != null) {
|
||||
when (intent.action) {
|
||||
ACTION_EXPORT_PASSWORD -> {
|
||||
val uri = intent.getParcelableExtra<Uri>("uri")
|
||||
if (uri != null) {
|
||||
val targetDirectory = DocumentFile.fromTreeUri(applicationContext, uri)
|
||||
|
||||
if (targetDirectory != null) {
|
||||
createNotification()
|
||||
exportPasswords(targetDirectory)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targetDirectory != null) {
|
||||
createNotification()
|
||||
exportPasswords(targetDirectory)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
}
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports passwords to the given directory.
|
||||
*
|
||||
* Recursively copies the existing password store to an external directory.
|
||||
*
|
||||
* @param targetDirectory directory to copy password directory to.
|
||||
*/
|
||||
private fun exportPasswords(targetDirectory: DocumentFile) {
|
||||
|
||||
val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory())
|
||||
val sourcePassDir = DocumentFile.fromFile(repositoryDirectory)
|
||||
|
||||
d { "Copying ${repositoryDirectory.path} to $targetDirectory" }
|
||||
|
||||
val dateString =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
|
||||
} else {
|
||||
String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z")))
|
||||
}
|
||||
|
||||
val passDir = targetDirectory.createDirectory("password_store_$dateString")
|
||||
|
||||
if (passDir != null) {
|
||||
copyDirToDir(sourcePassDir, passDir)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports passwords to the given directory.
|
||||
*
|
||||
* Recursively copies the existing password store to an external directory.
|
||||
*
|
||||
* @param targetDirectory directory to copy password directory to.
|
||||
*/
|
||||
private fun exportPasswords(targetDirectory: DocumentFile) {
|
||||
/**
|
||||
* Copies a password file to a given directory.
|
||||
*
|
||||
* Note: this does not preserve last modified time.
|
||||
*
|
||||
* @param passwordFile password file to copy.
|
||||
* @param targetDirectory target directory to copy password.
|
||||
*/
|
||||
private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) {
|
||||
val sourceInputStream = contentResolver.openInputStream(passwordFile.uri)
|
||||
val name = passwordFile.name
|
||||
val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!)
|
||||
if (targetPasswordFile?.exists() == true) {
|
||||
val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri)
|
||||
|
||||
val repositoryDirectory = requireNotNull(PasswordRepository.getRepositoryDirectory())
|
||||
val sourcePassDir = DocumentFile.fromFile(repositoryDirectory)
|
||||
if (destOutputStream != null && sourceInputStream != null) {
|
||||
sourceInputStream.copyTo(destOutputStream, 1024)
|
||||
|
||||
d { "Copying ${repositoryDirectory.path} to $targetDirectory" }
|
||||
|
||||
val dateString = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
LocalDateTime
|
||||
.now()
|
||||
.format(DateTimeFormatter.ISO_DATE_TIME)
|
||||
} else {
|
||||
String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z")))
|
||||
}
|
||||
|
||||
val passDir = targetDirectory.createDirectory("password_store_$dateString")
|
||||
|
||||
if (passDir != null) {
|
||||
copyDirToDir(sourcePassDir, passDir)
|
||||
}
|
||||
sourceInputStream.close()
|
||||
destOutputStream.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a password file to a given directory.
|
||||
*
|
||||
* Note: this does not preserve last modified time.
|
||||
*
|
||||
* @param passwordFile password file to copy.
|
||||
* @param targetDirectory target directory to copy password.
|
||||
*/
|
||||
private fun copyFileToDir(passwordFile: DocumentFile, targetDirectory: DocumentFile) {
|
||||
val sourceInputStream = contentResolver.openInputStream(passwordFile.uri)
|
||||
val name = passwordFile.name
|
||||
val targetPasswordFile = targetDirectory.createFile("application/octet-stream", name!!)
|
||||
if (targetPasswordFile?.exists() == true) {
|
||||
val destOutputStream = contentResolver.openOutputStream(targetPasswordFile.uri)
|
||||
|
||||
if (destOutputStream != null && sourceInputStream != null) {
|
||||
sourceInputStream.copyTo(destOutputStream, 1024)
|
||||
|
||||
sourceInputStream.close()
|
||||
destOutputStream.close()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Recursively copies a directory to a destination.
|
||||
*
|
||||
* @param sourceDirectory directory to copy from.
|
||||
* @param targetDirectory directory to copy to.
|
||||
*/
|
||||
private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) {
|
||||
sourceDirectory.listFiles().forEach { file ->
|
||||
if (file.isDirectory) {
|
||||
// Create new directory and recurse
|
||||
val newDir = targetDirectory.createDirectory(file.name!!)
|
||||
copyDirToDir(file, newDir!!)
|
||||
} else {
|
||||
copyFileToDir(file, targetDirectory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copies a directory to a destination.
|
||||
*
|
||||
* @param sourceDirectory directory to copy from.
|
||||
* @param targetDirectory directory to copy to.
|
||||
*/
|
||||
private fun copyDirToDir(sourceDirectory: DocumentFile, targetDirectory: DocumentFile) {
|
||||
sourceDirectory.listFiles().forEach { file ->
|
||||
if (file.isDirectory) {
|
||||
// Create new directory and recurse
|
||||
val newDir = targetDirectory.createDirectory(file.name!!)
|
||||
copyDirToDir(file, newDir!!)
|
||||
} else {
|
||||
copyFileToDir(file, targetDirectory)
|
||||
}
|
||||
}
|
||||
private fun createNotification() {
|
||||
createNotificationChannel()
|
||||
|
||||
val notification =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(R.string.exporting_passwords))
|
||||
.setSmallIcon(R.drawable.ic_round_import_export)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
|
||||
startForeground(2, notification)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val serviceChannel =
|
||||
NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW)
|
||||
val manager = getSystemService<NotificationManager>()
|
||||
if (manager != null) {
|
||||
manager.createNotificationChannel(serviceChannel)
|
||||
} else {
|
||||
d { "Failed to create notification channel" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification() {
|
||||
createNotificationChannel()
|
||||
companion object {
|
||||
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(R.string.exporting_passwords))
|
||||
.setSmallIcon(R.drawable.ic_round_import_export)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.build()
|
||||
|
||||
startForeground(2, notification)
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val serviceChannel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.app_name),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
val manager = getSystemService<NotificationManager>()
|
||||
if (manager != null) {
|
||||
manager.createNotificationChannel(serviceChannel)
|
||||
} else {
|
||||
d { "Failed to create notification channel" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD"
|
||||
private const val CHANNEL_ID = "NotificationService"
|
||||
}
|
||||
const val ACTION_EXPORT_PASSWORD = "ACTION_EXPORT_PASSWORD"
|
||||
private const val CHANNEL_ID = "NotificationService"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,191 +17,168 @@ import java.io.File
|
|||
import org.eclipse.jgit.transport.URIish
|
||||
|
||||
enum class Protocol(val pref: String) {
|
||||
Ssh("ssh://"),
|
||||
Https("https://"),
|
||||
;
|
||||
Ssh("ssh://"),
|
||||
Https("https://"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
private val map = values().associateBy(Protocol::pref)
|
||||
fun fromString(type: String?): Protocol {
|
||||
return map[type ?: return Ssh]
|
||||
?: throw IllegalArgumentException("$type is not a valid Protocol")
|
||||
}
|
||||
private val map = values().associateBy(Protocol::pref)
|
||||
fun fromString(type: String?): Protocol {
|
||||
return map[type ?: return Ssh] ?: throw IllegalArgumentException("$type is not a valid Protocol")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class AuthMode(val pref: String) {
|
||||
SshKey("ssh-key"),
|
||||
Password("username/password"),
|
||||
OpenKeychain("OpenKeychain"),
|
||||
None("None"),
|
||||
;
|
||||
SshKey("ssh-key"),
|
||||
Password("username/password"),
|
||||
OpenKeychain("OpenKeychain"),
|
||||
None("None"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
companion object {
|
||||
|
||||
private val map = values().associateBy(AuthMode::pref)
|
||||
fun fromString(type: String?): AuthMode {
|
||||
return map[type ?: return SshKey]
|
||||
?: throw IllegalArgumentException("$type is not a valid AuthMode")
|
||||
}
|
||||
private val map = values().associateBy(AuthMode::pref)
|
||||
fun fromString(type: String?): AuthMode {
|
||||
return map[type ?: return SshKey] ?: throw IllegalArgumentException("$type is not a valid AuthMode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object GitSettings {
|
||||
|
||||
private const val DEFAULT_BRANCH = "master"
|
||||
private const val DEFAULT_BRANCH = "master"
|
||||
|
||||
private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs }
|
||||
private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedGitPrefs() }
|
||||
private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() }
|
||||
private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" }
|
||||
private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs }
|
||||
private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) {
|
||||
Application.instance.getEncryptedGitPrefs()
|
||||
}
|
||||
private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() }
|
||||
private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" }
|
||||
|
||||
var authMode
|
||||
get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
|
||||
private set(value) {
|
||||
settings.edit {
|
||||
putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref)
|
||||
}
|
||||
}
|
||||
|
||||
var url
|
||||
get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL)
|
||||
private set(value) {
|
||||
require(value != null)
|
||||
if (value == url)
|
||||
return
|
||||
settings.edit {
|
||||
putString(PreferenceKeys.GIT_REMOTE_URL, value)
|
||||
}
|
||||
if (PasswordRepository.isInitialized)
|
||||
PasswordRepository.addRemote("origin", value, true)
|
||||
// When the server changes, remote password, multiplexing support and host key file
|
||||
// should be deleted/reset.
|
||||
useMultiplexing = true
|
||||
encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
|
||||
clearSavedHostKey()
|
||||
}
|
||||
|
||||
var authorName
|
||||
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
|
||||
set(value) {
|
||||
settings.edit {
|
||||
putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value)
|
||||
}
|
||||
}
|
||||
|
||||
var authorEmail
|
||||
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: ""
|
||||
set(value) {
|
||||
settings.edit {
|
||||
putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value)
|
||||
}
|
||||
}
|
||||
|
||||
var branch
|
||||
get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH
|
||||
private set(value) {
|
||||
settings.edit {
|
||||
putString(PreferenceKeys.GIT_BRANCH_NAME, value)
|
||||
}
|
||||
}
|
||||
|
||||
var useMultiplexing
|
||||
get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true)
|
||||
set(value) {
|
||||
settings.edit {
|
||||
putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value)
|
||||
}
|
||||
}
|
||||
|
||||
var proxyHost
|
||||
get() = proxySettings.getString(PreferenceKeys.PROXY_HOST)
|
||||
set(value) {
|
||||
proxySettings.edit {
|
||||
putString(PreferenceKeys.PROXY_HOST, value)
|
||||
}
|
||||
}
|
||||
|
||||
var proxyPort
|
||||
get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1)
|
||||
set(value) {
|
||||
proxySettings.edit {
|
||||
putInt(PreferenceKeys.PROXY_PORT, value)
|
||||
}
|
||||
}
|
||||
|
||||
var proxyUsername
|
||||
get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
|
||||
set(value) {
|
||||
proxySettings.edit {
|
||||
putString(PreferenceKeys.PROXY_USERNAME, value)
|
||||
}
|
||||
}
|
||||
|
||||
var proxyPassword
|
||||
get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD)
|
||||
set(value) {
|
||||
proxySettings.edit {
|
||||
putString(PreferenceKeys.PROXY_PASSWORD, value)
|
||||
}
|
||||
}
|
||||
|
||||
var rebaseOnPull
|
||||
get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true)
|
||||
set(value) {
|
||||
settings.edit {
|
||||
putBoolean(PreferenceKeys.REBASE_ON_PULL, value)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class UpdateConnectionSettingsResult {
|
||||
class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
|
||||
class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) : UpdateConnectionSettingsResult()
|
||||
object Valid : UpdateConnectionSettingsResult()
|
||||
object FailedToParseUrl : UpdateConnectionSettingsResult()
|
||||
var authMode
|
||||
get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
|
||||
private set(value) {
|
||||
settings.edit { putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) }
|
||||
}
|
||||
|
||||
fun updateConnectionSettingsIfValid(newAuthMode: AuthMode, newUrl: String, newBranch: String): UpdateConnectionSettingsResult {
|
||||
val parsedUrl = runCatching {
|
||||
URIish(newUrl)
|
||||
}.getOrElse {
|
||||
return UpdateConnectionSettingsResult.FailedToParseUrl
|
||||
}
|
||||
val newProtocol = when (parsedUrl.scheme) {
|
||||
in listOf("http", "https") -> Protocol.Https
|
||||
in listOf("ssh", null) -> Protocol.Ssh
|
||||
else -> return UpdateConnectionSettingsResult.FailedToParseUrl
|
||||
}
|
||||
if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank())
|
||||
return UpdateConnectionSettingsResult.MissingUsername(newProtocol)
|
||||
|
||||
val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password)
|
||||
val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey)
|
||||
when {
|
||||
newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> {
|
||||
return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth)
|
||||
}
|
||||
newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> {
|
||||
return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth)
|
||||
}
|
||||
}
|
||||
|
||||
url = newUrl
|
||||
authMode = newAuthMode
|
||||
branch = newBranch
|
||||
return UpdateConnectionSettingsResult.Valid
|
||||
var url
|
||||
get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL)
|
||||
private set(value) {
|
||||
require(value != null)
|
||||
if (value == url) return
|
||||
settings.edit { putString(PreferenceKeys.GIT_REMOTE_URL, value) }
|
||||
if (PasswordRepository.isInitialized) PasswordRepository.addRemote("origin", value, true)
|
||||
// When the server changes, remote password, multiplexing support and host key file
|
||||
// should be deleted/reset.
|
||||
useMultiplexing = true
|
||||
encryptedSettings.edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
|
||||
clearSavedHostKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a previously saved SSH host key
|
||||
*/
|
||||
fun clearSavedHostKey() {
|
||||
File(hostKeyPath).delete()
|
||||
var authorName
|
||||
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
|
||||
set(value) {
|
||||
settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a host key was previously saved
|
||||
*/
|
||||
fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists()
|
||||
var authorEmail
|
||||
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: ""
|
||||
set(value) {
|
||||
settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) }
|
||||
}
|
||||
|
||||
var branch
|
||||
get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH
|
||||
private set(value) {
|
||||
settings.edit { putString(PreferenceKeys.GIT_BRANCH_NAME, value) }
|
||||
}
|
||||
|
||||
var useMultiplexing
|
||||
get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true)
|
||||
set(value) {
|
||||
settings.edit { putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) }
|
||||
}
|
||||
|
||||
var proxyHost
|
||||
get() = proxySettings.getString(PreferenceKeys.PROXY_HOST)
|
||||
set(value) {
|
||||
proxySettings.edit { putString(PreferenceKeys.PROXY_HOST, value) }
|
||||
}
|
||||
|
||||
var proxyPort
|
||||
get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1)
|
||||
set(value) {
|
||||
proxySettings.edit { putInt(PreferenceKeys.PROXY_PORT, value) }
|
||||
}
|
||||
|
||||
var proxyUsername
|
||||
get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
|
||||
set(value) {
|
||||
proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) }
|
||||
}
|
||||
|
||||
var proxyPassword
|
||||
get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD)
|
||||
set(value) {
|
||||
proxySettings.edit { putString(PreferenceKeys.PROXY_PASSWORD, value) }
|
||||
}
|
||||
|
||||
var rebaseOnPull
|
||||
get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true)
|
||||
set(value) {
|
||||
settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, value) }
|
||||
}
|
||||
|
||||
sealed class UpdateConnectionSettingsResult {
|
||||
class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
|
||||
class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) :
|
||||
UpdateConnectionSettingsResult()
|
||||
object Valid : UpdateConnectionSettingsResult()
|
||||
object FailedToParseUrl : UpdateConnectionSettingsResult()
|
||||
}
|
||||
|
||||
fun updateConnectionSettingsIfValid(
|
||||
newAuthMode: AuthMode,
|
||||
newUrl: String,
|
||||
newBranch: String
|
||||
): UpdateConnectionSettingsResult {
|
||||
val parsedUrl =
|
||||
runCatching { URIish(newUrl) }.getOrElse {
|
||||
return UpdateConnectionSettingsResult.FailedToParseUrl
|
||||
}
|
||||
val newProtocol =
|
||||
when (parsedUrl.scheme) {
|
||||
in listOf("http", "https") -> Protocol.Https
|
||||
in listOf("ssh", null) -> Protocol.Ssh
|
||||
else -> return UpdateConnectionSettingsResult.FailedToParseUrl
|
||||
}
|
||||
if (newAuthMode != AuthMode.None && parsedUrl.user.isNullOrBlank())
|
||||
return UpdateConnectionSettingsResult.MissingUsername(newProtocol)
|
||||
|
||||
val validHttpsAuth = listOf(AuthMode.None, AuthMode.Password)
|
||||
val validSshAuth = listOf(AuthMode.OpenKeychain, AuthMode.Password, AuthMode.SshKey)
|
||||
when {
|
||||
newProtocol == Protocol.Https && newAuthMode !in validHttpsAuth -> {
|
||||
return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validHttpsAuth)
|
||||
}
|
||||
newProtocol == Protocol.Ssh && newAuthMode !in validSshAuth -> {
|
||||
return UpdateConnectionSettingsResult.AuthModeMismatch(newProtocol, validSshAuth)
|
||||
}
|
||||
}
|
||||
|
||||
url = newUrl
|
||||
authMode = newAuthMode
|
||||
branch = newBranch
|
||||
return UpdateConnectionSettingsResult.Valid
|
||||
}
|
||||
|
||||
/** Deletes a previously saved SSH host key */
|
||||
fun clearSavedHostKey() {
|
||||
File(hostKeyPath).delete()
|
||||
}
|
||||
|
||||
/** Returns true if a host key was previously saved */
|
||||
fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists()
|
||||
}
|
||||
|
|
|
@ -20,108 +20,100 @@ import java.io.File
|
|||
import java.net.URI
|
||||
|
||||
fun runMigrations(context: Context) {
|
||||
val sharedPrefs = context.sharedPrefs
|
||||
migrateToGitUrlBasedConfig(sharedPrefs)
|
||||
migrateToHideAll(sharedPrefs)
|
||||
migrateToSshKey(context, sharedPrefs)
|
||||
migrateToClipboardHistory(sharedPrefs)
|
||||
val sharedPrefs = context.sharedPrefs
|
||||
migrateToGitUrlBasedConfig(sharedPrefs)
|
||||
migrateToHideAll(sharedPrefs)
|
||||
migrateToSshKey(context, sharedPrefs)
|
||||
migrateToClipboardHistory(sharedPrefs)
|
||||
}
|
||||
|
||||
private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) {
|
||||
val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER)
|
||||
?: return
|
||||
i { "Migrating to URL-based Git config" }
|
||||
val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: ""
|
||||
val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: ""
|
||||
val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: ""
|
||||
val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
|
||||
val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) ?: return
|
||||
i { "Migrating to URL-based Git config" }
|
||||
val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: ""
|
||||
val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: ""
|
||||
val serverPath = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_LOCATION) ?: ""
|
||||
val protocol = Protocol.fromString(sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PROTOCOL))
|
||||
|
||||
// Whether we need the leading ssh:// depends on the use of a custom port.
|
||||
val hostnamePart = serverHostname.removePrefix("ssh://")
|
||||
val url = when (protocol) {
|
||||
Protocol.Ssh -> {
|
||||
val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@"
|
||||
val portPart =
|
||||
if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort"
|
||||
if (portPart.isEmpty()) {
|
||||
"$userPart$hostnamePart:$serverPath"
|
||||
} else {
|
||||
// Only absolute paths are supported with custom ports.
|
||||
if (!serverPath.startsWith('/'))
|
||||
null
|
||||
else
|
||||
// We have to specify the ssh scheme as this is the only way to pass a custom
|
||||
// port.
|
||||
"ssh://$userPart$hostnamePart$portPart$serverPath"
|
||||
}
|
||||
}
|
||||
Protocol.Https -> {
|
||||
val portPart =
|
||||
if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort"
|
||||
val pathPart = serverPath.trimStart('/', ':')
|
||||
val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart"
|
||||
val url = when {
|
||||
urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme
|
||||
urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https")
|
||||
else -> "https://$urlWithFreeEntryScheme"
|
||||
}
|
||||
runCatching {
|
||||
if (URI(url).rawAuthority != null)
|
||||
url
|
||||
else
|
||||
null
|
||||
}.get()
|
||||
// Whether we need the leading ssh:// depends on the use of a custom port.
|
||||
val hostnamePart = serverHostname.removePrefix("ssh://")
|
||||
val url =
|
||||
when (protocol) {
|
||||
Protocol.Ssh -> {
|
||||
val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@"
|
||||
val portPart = if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort"
|
||||
if (portPart.isEmpty()) {
|
||||
"$userPart$hostnamePart:$serverPath"
|
||||
} else {
|
||||
// Only absolute paths are supported with custom ports.
|
||||
if (!serverPath.startsWith('/')) null
|
||||
else
|
||||
// We have to specify the ssh scheme as this is the only way to pass a custom
|
||||
// port.
|
||||
"ssh://$userPart$hostnamePart$portPart$serverPath"
|
||||
}
|
||||
}
|
||||
Protocol.Https -> {
|
||||
val portPart = if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort"
|
||||
val pathPart = serverPath.trimStart('/', ':')
|
||||
val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart"
|
||||
val url =
|
||||
when {
|
||||
urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme
|
||||
urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https")
|
||||
else -> "https://$urlWithFreeEntryScheme"
|
||||
}
|
||||
runCatching { if (URI(url).rawAuthority != null) url else null }.get()
|
||||
}
|
||||
}
|
||||
|
||||
sharedPrefs.edit {
|
||||
remove(PreferenceKeys.GIT_REMOTE_LOCATION)
|
||||
remove(PreferenceKeys.GIT_REMOTE_PORT)
|
||||
remove(PreferenceKeys.GIT_REMOTE_SERVER)
|
||||
remove(PreferenceKeys.GIT_REMOTE_USERNAME)
|
||||
remove(PreferenceKeys.GIT_REMOTE_PROTOCOL)
|
||||
}
|
||||
if (url == null || GitSettings.updateConnectionSettingsIfValid(
|
||||
newAuthMode = GitSettings.authMode,
|
||||
newUrl = url,
|
||||
newBranch = GitSettings.branch) != GitSettings.UpdateConnectionSettingsResult.Valid) {
|
||||
e { "Failed to migrate to URL-based Git config, generated URL is invalid" }
|
||||
}
|
||||
sharedPrefs.edit {
|
||||
remove(PreferenceKeys.GIT_REMOTE_LOCATION)
|
||||
remove(PreferenceKeys.GIT_REMOTE_PORT)
|
||||
remove(PreferenceKeys.GIT_REMOTE_SERVER)
|
||||
remove(PreferenceKeys.GIT_REMOTE_USERNAME)
|
||||
remove(PreferenceKeys.GIT_REMOTE_PROTOCOL)
|
||||
}
|
||||
if (url == null ||
|
||||
GitSettings.updateConnectionSettingsIfValid(
|
||||
newAuthMode = GitSettings.authMode,
|
||||
newUrl = url,
|
||||
newBranch = GitSettings.branch
|
||||
) != GitSettings.UpdateConnectionSettingsResult.Valid
|
||||
) {
|
||||
e { "Failed to migrate to URL-based Git config, generated URL is invalid" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateToHideAll(sharedPrefs: SharedPreferences) {
|
||||
sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return
|
||||
val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
|
||||
sharedPrefs.edit {
|
||||
remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS)
|
||||
putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden)
|
||||
}
|
||||
sharedPrefs.all[PreferenceKeys.SHOW_HIDDEN_FOLDERS] ?: return
|
||||
val isHidden = sharedPrefs.getBoolean(PreferenceKeys.SHOW_HIDDEN_FOLDERS, false)
|
||||
sharedPrefs.edit {
|
||||
remove(PreferenceKeys.SHOW_HIDDEN_FOLDERS)
|
||||
putBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, isHidden)
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) {
|
||||
val privateKeyFile = File(context.filesDir, ".ssh_key")
|
||||
if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) &&
|
||||
!SshKey.exists &&
|
||||
privateKeyFile.exists()) {
|
||||
// Currently uses a private key imported or generated with an old version of Password Store.
|
||||
// Generated keys come with a public key which the user should still be able to view after
|
||||
// the migration (not possible for regular imported keys), hence the special case.
|
||||
val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
|
||||
SshKey.useLegacyKey(isGeneratedKey)
|
||||
sharedPrefs.edit {
|
||||
remove(PreferenceKeys.USE_GENERATED_KEY)
|
||||
}
|
||||
}
|
||||
val privateKeyFile = File(context.filesDir, ".ssh_key")
|
||||
if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && !SshKey.exists && privateKeyFile.exists()) {
|
||||
// Currently uses a private key imported or generated with an old version of Password Store.
|
||||
// Generated keys come with a public key which the user should still be able to view after
|
||||
// the migration (not possible for regular imported keys), hence the special case.
|
||||
val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
|
||||
SshKey.useLegacyKey(isGeneratedKey)
|
||||
sharedPrefs.edit { remove(PreferenceKeys.USE_GENERATED_KEY) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun migrateToClipboardHistory(sharedPrefs: SharedPreferences) {
|
||||
if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) {
|
||||
sharedPrefs.edit {
|
||||
putBoolean(
|
||||
PreferenceKeys.CLEAR_CLIPBOARD_HISTORY,
|
||||
sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
|
||||
)
|
||||
remove(PreferenceKeys.CLEAR_CLIPBOARD_20X)
|
||||
}
|
||||
if (sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X)) {
|
||||
sharedPrefs.edit {
|
||||
putBoolean(
|
||||
PreferenceKeys.CLEAR_CLIPBOARD_HISTORY,
|
||||
sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, false)
|
||||
)
|
||||
remove(PreferenceKeys.CLEAR_CLIPBOARD_20X)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,37 +13,36 @@ import dev.msfjarvis.aps.util.extensions.base64
|
|||
import dev.msfjarvis.aps.util.extensions.getString
|
||||
|
||||
enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) {
|
||||
|
||||
FOLDER_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem ->
|
||||
(p1.type + p1.name)
|
||||
.compareTo(p2.type + p2.name, ignoreCase = true)
|
||||
}),
|
||||
|
||||
INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem ->
|
||||
p1.name.compareTo(p2.name, ignoreCase = true)
|
||||
}),
|
||||
|
||||
RECENTLY_USED(Comparator { p1: PasswordItem, p2: PasswordItem ->
|
||||
val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
||||
val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
|
||||
val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
|
||||
when {
|
||||
timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1)
|
||||
timeP1 != null && timeP2 == null -> return@Comparator -1
|
||||
timeP1 == null && timeP2 != null -> return@Comparator 1
|
||||
else -> p1.name.compareTo(p2.name, ignoreCase = true)
|
||||
}
|
||||
}),
|
||||
|
||||
FILE_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem ->
|
||||
(p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
|
||||
});
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
|
||||
return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name)
|
||||
}
|
||||
FOLDER_FIRST(
|
||||
Comparator { p1: PasswordItem, p2: PasswordItem ->
|
||||
(p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true)
|
||||
}
|
||||
),
|
||||
INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem -> p1.name.compareTo(p2.name, ignoreCase = true) }),
|
||||
RECENTLY_USED(
|
||||
Comparator { p1: PasswordItem, p2: PasswordItem ->
|
||||
val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
||||
val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
|
||||
val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
|
||||
when {
|
||||
timeP1 != null && timeP2 != null -> timeP2.compareTo(timeP1)
|
||||
timeP1 != null && timeP2 == null -> return@Comparator -1
|
||||
timeP1 == null && timeP2 != null -> return@Comparator 1
|
||||
else -> p1.name.compareTo(p2.name, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
),
|
||||
FILE_FIRST(
|
||||
Comparator { p1: PasswordItem, p2: PasswordItem ->
|
||||
(p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
|
||||
}
|
||||
);
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun getSortOrder(settings: SharedPreferences): PasswordSortOrder {
|
||||
return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,85 +7,79 @@ package dev.msfjarvis.aps.util.settings
|
|||
|
||||
object PreferenceKeys {
|
||||
|
||||
const val APP_THEME = "app_theme"
|
||||
const val APP_VERSION = "app_version"
|
||||
const val AUTOFILL_ENABLE = "autofill_enable"
|
||||
const val BIOMETRIC_AUTH = "biometric_auth"
|
||||
@Deprecated(
|
||||
message = "Use CLEAR_CLIPBOARD_HISTORY instead",
|
||||
replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"),
|
||||
)
|
||||
const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x"
|
||||
const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history"
|
||||
const val CLEAR_SAVED_PASS = "clear_saved_pass"
|
||||
const val COPY_ON_DECRYPT = "copy_on_decrypt"
|
||||
const val ENABLE_DEBUG_LOGGING = "enable_debug_logging"
|
||||
const val EXPORT_PASSWORDS = "export_passwords"
|
||||
const val FILTER_RECURSIVELY = "filter_recursively"
|
||||
const val GENERAL_SHOW_TIME = "general_show_time"
|
||||
const val GIT_CONFIG = "git_config"
|
||||
const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email"
|
||||
const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name"
|
||||
const val GIT_EXTERNAL = "git_external"
|
||||
const val GIT_EXTERNAL_REPO = "git_external_repo"
|
||||
const val GIT_REMOTE_AUTH = "git_remote_auth"
|
||||
const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type"
|
||||
const val APP_THEME = "app_theme"
|
||||
const val APP_VERSION = "app_version"
|
||||
const val AUTOFILL_ENABLE = "autofill_enable"
|
||||
const val BIOMETRIC_AUTH = "biometric_auth"
|
||||
@Deprecated(
|
||||
message = "Use CLEAR_CLIPBOARD_HISTORY instead",
|
||||
replaceWith = ReplaceWith("PreferenceKeys.CLEAR_CLIPBOARD_HISTORY"),
|
||||
)
|
||||
const val CLEAR_CLIPBOARD_20X = "clear_clipboard_20x"
|
||||
const val CLEAR_CLIPBOARD_HISTORY = "clear_clipboard_history"
|
||||
const val CLEAR_SAVED_PASS = "clear_saved_pass"
|
||||
const val COPY_ON_DECRYPT = "copy_on_decrypt"
|
||||
const val ENABLE_DEBUG_LOGGING = "enable_debug_logging"
|
||||
const val EXPORT_PASSWORDS = "export_passwords"
|
||||
const val FILTER_RECURSIVELY = "filter_recursively"
|
||||
const val GENERAL_SHOW_TIME = "general_show_time"
|
||||
const val GIT_CONFIG = "git_config"
|
||||
const val GIT_CONFIG_AUTHOR_EMAIL = "git_config_user_email"
|
||||
const val GIT_CONFIG_AUTHOR_NAME = "git_config_user_name"
|
||||
const val GIT_EXTERNAL = "git_external"
|
||||
const val GIT_EXTERNAL_REPO = "git_external_repo"
|
||||
const val GIT_REMOTE_AUTH = "git_remote_auth"
|
||||
const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type"
|
||||
|
||||
@Deprecated("Use GIT_REMOTE_URL instead")
|
||||
const val GIT_REMOTE_LOCATION = "git_remote_location"
|
||||
const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing"
|
||||
@Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_LOCATION = "git_remote_location"
|
||||
const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing"
|
||||
|
||||
@Deprecated("Use GIT_REMOTE_URL instead")
|
||||
const val GIT_REMOTE_PORT = "git_remote_port"
|
||||
@Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PORT = "git_remote_port"
|
||||
|
||||
@Deprecated("Use GIT_REMOTE_URL instead")
|
||||
const val GIT_REMOTE_PROTOCOL = "git_remote_protocol"
|
||||
const val GIT_DELETE_REPO = "git_delete_repo"
|
||||
@Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PROTOCOL = "git_remote_protocol"
|
||||
const val GIT_DELETE_REPO = "git_delete_repo"
|
||||
|
||||
@Deprecated("Use GIT_REMOTE_URL instead")
|
||||
const val GIT_REMOTE_SERVER = "git_remote_server"
|
||||
const val GIT_REMOTE_URL = "git_remote_url"
|
||||
@Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_SERVER = "git_remote_server"
|
||||
const val GIT_REMOTE_URL = "git_remote_url"
|
||||
|
||||
@Deprecated("Use GIT_REMOTE_URL instead")
|
||||
const val GIT_REMOTE_USERNAME = "git_remote_username"
|
||||
const val GIT_SERVER_INFO = "git_server_info"
|
||||
const val GIT_BRANCH_NAME = "git_branch"
|
||||
const val HTTPS_PASSWORD = "https_password"
|
||||
const val LENGTH = "length"
|
||||
const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes"
|
||||
const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username"
|
||||
const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure"
|
||||
const val PREF_KEY_CUSTOM_DICT = "pref_key_custom_dict"
|
||||
const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict"
|
||||
const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type"
|
||||
const val PREF_SELECT_EXTERNAL = "pref_select_external"
|
||||
const val REPOSITORY_INITIALIZED = "repository_initialized"
|
||||
const val REPO_CHANGED = "repo_changed"
|
||||
const val SEARCH_ON_START = "search_on_start"
|
||||
@Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_USERNAME = "git_remote_username"
|
||||
const val GIT_SERVER_INFO = "git_server_info"
|
||||
const val GIT_BRANCH_NAME = "git_branch"
|
||||
const val HTTPS_PASSWORD = "https_password"
|
||||
const val LENGTH = "length"
|
||||
const val OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES = "oreo_autofill_custom_public_suffixes"
|
||||
const val OREO_AUTOFILL_DEFAULT_USERNAME = "oreo_autofill_default_username"
|
||||
const val OREO_AUTOFILL_DIRECTORY_STRUCTURE = "oreo_autofill_directory_structure"
|
||||
const val PREF_KEY_CUSTOM_DICT = "pref_key_custom_dict"
|
||||
const val PREF_KEY_IS_CUSTOM_DICT = "pref_key_is_custom_dict"
|
||||
const val PREF_KEY_PWGEN_TYPE = "pref_key_pwgen_type"
|
||||
const val PREF_SELECT_EXTERNAL = "pref_select_external"
|
||||
const val REPOSITORY_INITIALIZED = "repository_initialized"
|
||||
const val REPO_CHANGED = "repo_changed"
|
||||
const val SEARCH_ON_START = "search_on_start"
|
||||
|
||||
@Deprecated(
|
||||
message = "Use SHOW_HIDDEN_CONTENTS instead",
|
||||
replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS")
|
||||
)
|
||||
const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders"
|
||||
const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents"
|
||||
const val SORT_ORDER = "sort_order"
|
||||
const val SHOW_PASSWORD = "show_password"
|
||||
const val SSH_KEY = "ssh_key"
|
||||
const val SSH_KEYGEN = "ssh_keygen"
|
||||
const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase"
|
||||
const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid"
|
||||
const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid"
|
||||
const val SSH_SEE_KEY = "ssh_see_key"
|
||||
@Deprecated(
|
||||
message = "Use SHOW_HIDDEN_CONTENTS instead",
|
||||
replaceWith = ReplaceWith("PreferenceKeys.SHOW_HIDDEN_CONTENTS")
|
||||
)
|
||||
const val SHOW_HIDDEN_FOLDERS = "show_hidden_folders"
|
||||
const val SHOW_HIDDEN_CONTENTS = "show_hidden_contents"
|
||||
const val SORT_ORDER = "sort_order"
|
||||
const val SHOW_PASSWORD = "show_password"
|
||||
const val SSH_KEY = "ssh_key"
|
||||
const val SSH_KEYGEN = "ssh_keygen"
|
||||
const val SSH_KEY_LOCAL_PASSPHRASE = "ssh_key_local_passphrase"
|
||||
const val SSH_OPENKEYSTORE_CLEAR_KEY_ID = "ssh_openkeystore_clear_keyid"
|
||||
const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid"
|
||||
const val SSH_SEE_KEY = "ssh_see_key"
|
||||
|
||||
@Deprecated("To be used only in Migrations.kt")
|
||||
const val USE_GENERATED_KEY = "use_generated_key"
|
||||
@Deprecated("To be used only in Migrations.kt") const val USE_GENERATED_KEY = "use_generated_key"
|
||||
|
||||
const val PROXY_SETTINGS = "proxy_settings"
|
||||
const val PROXY_HOST = "proxy_host"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
const val PROXY_USERNAME = "proxy_username"
|
||||
const val PROXY_PASSWORD = "proxy_password"
|
||||
const val PROXY_SETTINGS = "proxy_settings"
|
||||
const val PROXY_HOST = "proxy_host"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
const val PROXY_USERNAME = "proxy_username"
|
||||
const val PROXY_PASSWORD = "proxy_password"
|
||||
|
||||
const val REBASE_ON_PULL = "rebase_on_pull"
|
||||
const val REBASE_ON_PULL = "rebase_on_pull"
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue