all: reformat with ktfmt

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

View file

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

View file

@ -12,9 +12,7 @@ plugins {
`crowdin-plugin` `crowdin-plugin`
} }
configure<CrowdinExtension> { configure<CrowdinExtension> { projectName = "android-password-store" }
projectName = "android-password-store"
}
android { android {
if (isSnapshot()) { if (isSnapshot()) {
@ -38,10 +36,8 @@ android {
flavorDimensions("free") flavorDimensions("free")
productFlavors { productFlavors {
create("free") { create("free") {}
} create("nonFree") {}
create("nonFree") {
}
} }
} }

View file

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

View file

@ -19,7 +19,8 @@ import org.junit.Test
class MigrationsTest { class MigrationsTest {
private fun checkOldKeysAreRemoved(context: Context) = with(context.sharedPrefs) { private fun checkOldKeysAreRemoved(context: Context) =
with(context.sharedPrefs) {
assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT)) assertNull(getString(PreferenceKeys.GIT_REMOTE_PORT))
assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME)) assertNull(getString(PreferenceKeys.GIT_REMOTE_USERNAME))
assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER)) assertNull(getString(PreferenceKeys.GIT_REMOTE_SERVER))

View file

@ -15,7 +15,10 @@ class UriTotpFinderTest {
@Test @Test
fun findSecret() { fun findSecret() {
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI)) assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(TOTP_URI))
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")) assertEquals(
"HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ",
totpFinder.findSecret("name\npassword\ntotp: HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ")
)
assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT)) assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", totpFinder.findSecret(PASS_FILE_CONTENT))
} }
@ -39,7 +42,8 @@ class UriTotpFinderTest {
companion object { companion object {
const val TOTP_URI = "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA256&digits=12&period=25" const val TOTP_URI =
"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 PASS_FILE_CONTENT = "password\n$TOTP_URI"
} }
} }

View file

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

View file

@ -27,8 +27,7 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
if (BuildConfig.ENABLE_DEBUG_FEATURES || if (BuildConfig.ENABLE_DEBUG_FEATURES || prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) {
prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) {
plant(DebugTree()) plant(DebugTree())
} }
prefs.registerOnSharedPreferenceChangeListener(this) prefs.registerOnSharedPreferenceChangeListener(this)
@ -50,13 +49,14 @@ class Application : android.app.Application(), SharedPreferences.OnSharedPrefere
} }
private fun setNightMode() { private fun setNightMode() {
AppCompatDelegate.setDefaultNightMode(when (prefs.getString(PreferenceKeys.APP_THEME) AppCompatDelegate.setDefaultNightMode(
?: getString(R.string.app_theme_def)) { when (prefs.getString(PreferenceKeys.APP_THEME) ?: getString(R.string.app_theme_def)) {
"light" -> MODE_NIGHT_NO "light" -> MODE_NIGHT_NO
"dark" -> MODE_NIGHT_YES "dark" -> MODE_NIGHT_YES
"follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM "follow_system" -> MODE_NIGHT_FOLLOW_SYSTEM
else -> MODE_NIGHT_AUTO_BATTERY else -> MODE_NIGHT_AUTO_BATTERY
}) }
)
} }
companion object { companion object {

View file

@ -7,11 +7,14 @@ package dev.msfjarvis.aps.data.password
class FieldItem(val key: String, val value: String, val action: ActionType) { class FieldItem(val key: String, val value: String, val action: ActionType) {
enum class ActionType { enum class ActionType {
COPY, HIDE COPY,
HIDE
} }
enum class ItemType(val type: String) { enum class ItemType(val type: String) {
USERNAME("Username"), PASSWORD("Password"), OTP("OTP") USERNAME("Username"),
PASSWORD("Password"),
OTP("OTP")
} }
companion object { companion object {

View file

@ -21,15 +21,12 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
val password: String val password: String
val username: String? val username: String?
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val digits: String
val digits: String
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpSecret: String?
val totpSecret: String?
val totpPeriod: Long val totpPeriod: Long
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val totpAlgorithm: String
val totpAlgorithm: String
val extraContent: String val extraContent: String
val extraContentWithoutAuthData: String val extraContentWithoutAuthData: String
val extraContentMap: Map<String, String> val extraContentMap: Map<String, String>
@ -66,8 +63,7 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
} }
fun calculateTotpCode(): String? { fun calculateTotpCode(): String? {
if (totpSecret == null) if (totpSecret == null) return null
return null
return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get() return Otp.calculateCode(totpSecret, Date().time / (1000 * totpPeriod), totpAlgorithm, digits).get()
} }
@ -81,22 +77,23 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
foundUsername = true foundUsername = true
false false
} }
line.startsWith("otpauth://", ignoreCase = true) || line.startsWith("otpauth://", ignoreCase = true) || line.startsWith("totp:", ignoreCase = true) -> {
line.startsWith("totp:", ignoreCase = true) -> {
false false
} }
else -> { else -> {
true true
} }
} }
}.joinToString(separator = "\n") }
.joinToString(separator = "\n")
} }
private fun generateExtraContentPairs(): Map<String, String> { private fun generateExtraContentPairs(): Map<String, String> {
fun MutableMap<String, String>.putOrAppend(key: String, value: String) { fun MutableMap<String, String>.putOrAppend(key: String, value: String) {
if (value.isEmpty()) return if (value.isEmpty()) return
val existing = this[key] val existing = this[key]
this[key] = if (existing == null) { this[key] =
if (existing == null) {
value value
} else { } else {
"$existing\n$value" "$existing\n$value"
@ -112,12 +109,14 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
// Take the first element of the array. This will be the key for the key-value pair. // Take the first element of the array. This will be the key for the key-value pair.
// ["ABC ", " DEF", "GHI"] -> key = "ABC" // ["ABC ", " DEF", "GHI"] -> key = "ABC"
val key = splitArray.first().trimEnd() val key = splitArray.first().trimEnd()
// Remove the first element from the array and join the rest of the string again with ':' as separator. // Remove the first element from the array and join the rest of the string again with
// ':' as separator.
// ["ABC ", " DEF", "GHI"] -> value = "DEF:GHI" // ["ABC ", " DEF", "GHI"] -> value = "DEF:GHI"
val value = splitArray.drop(1).joinToString(":").trimStart() val value = splitArray.drop(1).joinToString(":").trimStart()
if (key.isNotEmpty() && value.isNotEmpty()) { 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. // 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" // key = "ABC", value = "DEF:GHI"
items[key] = value items[key] = value
} else { } else {
@ -133,8 +132,7 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
private fun findUsername(): String? { private fun findUsername(): String? {
extraContent.splitToSequence("\n").forEach { line -> extraContent.splitToSequence("\n").forEach { line ->
for (prefix in USERNAME_FIELDS) { for (prefix in USERNAME_FIELDS) {
if (line.startsWith(prefix, ignoreCase = true)) if (line.startsWith(prefix, ignoreCase = true)) return line.substring(prefix.length).trimStart()
return line.substring(prefix.length).trimStart()
} }
} }
return null return null
@ -173,7 +171,8 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
private const val EXTRA_CONTENT = "Extra Content" private const val EXTRA_CONTENT = "Extra Content"
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val USERNAME_FIELDS = arrayOf( val USERNAME_FIELDS =
arrayOf(
"login:", "login:",
"username:", "username:",
"user:", "user:",
@ -186,7 +185,8 @@ class PasswordEntry(content: String, private val totpFinder: TotpFinder = UriTot
) )
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
val PASSWORD_FIELDS = arrayOf( val PASSWORD_FIELDS =
arrayOf(
"password:", "password:",
"secret:", "secret:",
"pass:", "pass:",

View file

@ -15,14 +15,9 @@ data class PasswordItem(
val rootDir: File val rootDir: File
) : Comparable<PasswordItem> { ) : Comparable<PasswordItem> {
val fullPathToParent = file.absolutePath val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "")
.replace(rootDir.absolutePath, "")
.replace(file.name, "")
val longName = BasePgpActivity.getLongName( val longName = BasePgpActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString())
fullPathToParent,
rootDir.absolutePath,
toString())
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
return (other is PasswordItem) && (other.file == file) return (other is PasswordItem) && (other.file == file)
@ -46,40 +41,22 @@ data class PasswordItem(
const val TYPE_PASSWORD = 'p' const val TYPE_PASSWORD = 'p'
@JvmStatic @JvmStatic
fun newCategory( fun newCategory(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
name: String,
file: File,
parent: PasswordItem,
rootDir: File
): PasswordItem {
return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir) return PasswordItem(name, parent, TYPE_CATEGORY, file, rootDir)
} }
@JvmStatic @JvmStatic
fun newCategory( fun newCategory(name: String, file: File, rootDir: File): PasswordItem {
name: String,
file: File,
rootDir: File
): PasswordItem {
return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir) return PasswordItem(name, null, TYPE_CATEGORY, file, rootDir)
} }
@JvmStatic @JvmStatic
fun newPassword( fun newPassword(name: String, file: File, parent: PasswordItem, rootDir: File): PasswordItem {
name: String,
file: File,
parent: PasswordItem,
rootDir: File
): PasswordItem {
return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir) return PasswordItem(name, parent, TYPE_PASSWORD, file, rootDir)
} }
@JvmStatic @JvmStatic
fun newPassword( fun newPassword(name: String, file: File, rootDir: File): PasswordItem {
name: String,
file: File,
rootDir: File
): PasswordItem {
return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir) return PasswordItem(name, null, TYPE_PASSWORD, file, rootDir)
} }
} }

View file

@ -42,8 +42,7 @@ object PasswordRepository {
override fun createSymLink(source: File, target: String) { override fun createSymLink(source: File, target: String) {
val sourcePath = source.toPath() val sourcePath = source.toPath()
if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) if (Files.exists(sourcePath, LinkOption.NOFOLLOW_LINKS)) Files.delete(sourcePath)
Files.delete(sourcePath)
Files.createSymbolicLink(sourcePath, File(target).toPath()) Files.createSymbolicLink(sourcePath, File(target).toPath())
} }
} }
@ -71,15 +70,19 @@ object PasswordRepository {
fun getRepository(localDir: File?): Repository? { fun getRepository(localDir: File?): Repository? {
if (repository == null && localDir != null) { if (repository == null && localDir != null) {
val builder = FileRepositoryBuilder() val builder = FileRepositoryBuilder()
repository = runCatching { repository =
builder.run { runCatching {
builder
.run {
gitDir = localDir gitDir = localDir
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
fs = Java7FSFactory().detect(null) fs = Java7FSFactory().detect(null)
} }
readEnvironment() readEnvironment()
}.build() }
}.getOrElse { e -> .build()
}
.getOrElse { e ->
e.printStackTrace() e.printStackTrace()
null null
} }
@ -128,9 +131,8 @@ object PasswordRepository {
remoteConfig.update(storedConfig) remoteConfig.update(storedConfig)
storedConfig.save() storedConfig.save()
}.onFailure { e ->
e.printStackTrace()
} }
.onFailure { e -> e.printStackTrace() }
} else if (replace) { } else if (replace) {
runCatching { runCatching {
val uri = URIish(url) val uri = URIish(url)
@ -150,9 +152,8 @@ object PasswordRepository {
remoteConfig.update(storedConfig) remoteConfig.update(storedConfig)
storedConfig.save() storedConfig.save()
}.onFailure { e ->
e.printStackTrace()
} }
.onFailure { e -> e.printStackTrace() }
} }
} }
@ -166,10 +167,7 @@ object PasswordRepository {
fun getRepositoryDirectory(): File { fun getRepositoryDirectory(): File {
return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) { return if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)) {
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
if (externalRepo != null) if (externalRepo != null) File(externalRepo) else File(filesDir.toString(), "/store")
File(externalRepo)
else
File(filesDir.toString(), "/store")
} else { } else {
File(filesDir.toString(), "/store") File(filesDir.toString(), "/store")
} }
@ -201,10 +199,8 @@ object PasswordRepository {
fun getFilesList(path: File?): ArrayList<File> { fun getFilesList(path: File?): ArrayList<File> {
if (path == null || !path.exists()) return ArrayList() if (path == null || !path.exists()) return ArrayList()
val directories = (path.listFiles(FileFilter { pathname -> pathname.isDirectory }) val directories = (path.listFiles(FileFilter { pathname -> pathname.isDirectory }) ?: emptyArray()).toList()
?: emptyArray()).toList() val files = (path.listFiles(FileFilter { pathname -> pathname.extension == "gpg" }) ?: emptyArray()).toList()
val files = (path.listFiles(FileFilter { pathname -> pathname.extension == "gpg" })
?: emptyArray()).toList()
val items = ArrayList<File>() val items = ArrayList<File>()
items.addAll(directories) items.addAll(directories)
@ -231,11 +227,13 @@ object PasswordRepository {
passList.filter { !it.isHidden }.toCollection(passList.apply { clear() }) passList.filter { !it.isHidden }.toCollection(passList.apply { clear() })
} }
passList.forEach { file -> passList.forEach { file ->
passwordList.add(if (file.isFile) { passwordList.add(
if (file.isFile) {
PasswordItem.newPassword(file.name, file, rootDir) PasswordItem.newPassword(file.name, file, rootDir)
} else { } else {
PasswordItem.newCategory(file.name, file, rootDir) PasswordItem.newCategory(file.name, file, rootDir)
}) }
)
} }
passwordList.sortWith(sortOrder.comparator) passwordList.sortWith(sortOrder.comparator)
return passwordList return passwordList

View file

@ -36,8 +36,9 @@ class FieldItemAdapter(
} }
fun updateOTPCode(code: String) { fun updateOTPCode(code: String) {
var otpItemPosition = -1; var otpItemPosition = -1
fieldItemList = fieldItemList.mapIndexed { position, item -> fieldItemList =
fieldItemList.mapIndexed { position, item ->
if (item.key.equals(FieldItem.ItemType.OTP.type, true)) { if (item.key.equals(FieldItem.ItemType.OTP.type, true)) {
otpItemPosition = position otpItemPosition = position
return@mapIndexed FieldItem.createOtpField(code) return@mapIndexed FieldItem.createOtpField(code)
@ -54,8 +55,7 @@ class FieldItemAdapter(
notifyDataSetChanged() notifyDataSetChanged()
} }
class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : RecyclerView.ViewHolder(itemView) {
RecyclerView.ViewHolder(itemView) {
fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) { fun bind(fieldItem: FieldItem, showPassword: Boolean, copyTextToClipBoard: (String?) -> Unit) {
with(binding) { with(binding) {

View file

@ -29,7 +29,9 @@ open class PasswordItemRecyclerAdapter :
makeSelectable(recyclerView, ::PasswordItemDetailsLookup) makeSelectable(recyclerView, ::PasswordItemDetailsLookup)
} }
override fun onItemClicked(listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit): PasswordItemRecyclerAdapter { override fun onItemClicked(
listener: (holder: PasswordItemViewHolder, item: PasswordItem) -> Unit
): PasswordItemRecyclerAdapter {
return super.onItemClicked(listener) as PasswordItemRecyclerAdapter return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
} }
@ -41,13 +43,13 @@ open class PasswordItemRecyclerAdapter :
private val name: AppCompatTextView = itemView.findViewById(R.id.label) private val name: AppCompatTextView = itemView.findViewById(R.id.label)
private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count) private val childCount: AppCompatTextView = itemView.findViewById(R.id.child_count)
private val folderIndicator: AppCompatImageView = private val folderIndicator: AppCompatImageView = itemView.findViewById(R.id.folder_indicator)
itemView.findViewById(R.id.folder_indicator)
lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String> lateinit var itemDetails: ItemDetailsLookup.ItemDetails<String>
fun bind(item: PasswordItem) { fun bind(item: PasswordItem) {
val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "") val parentPath = item.fullPathToParent.replace("(^/)|(/$)".toRegex(), "")
val source = if (parentPath.isNotEmpty()) { val source =
if (parentPath.isNotEmpty()) {
"$parentPath\n$item" "$parentPath\n$item"
} else { } else {
"$item" "$item"
@ -57,23 +59,22 @@ open class PasswordItemRecyclerAdapter :
name.text = spannable name.text = spannable
if (item.type == PasswordItem.TYPE_CATEGORY) { if (item.type == PasswordItem.TYPE_CATEGORY) {
folderIndicator.visibility = View.VISIBLE folderIndicator.visibility = View.VISIBLE
val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size val count = item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0
?: 0
childCount.visibility = if (count > 0) View.VISIBLE else View.GONE childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
childCount.text = "$count" childCount.text = "$count"
} else { } else {
childCount.visibility = View.GONE childCount.visibility = View.GONE
folderIndicator.visibility = View.GONE folderIndicator.visibility = View.GONE
} }
itemDetails = object : ItemDetailsLookup.ItemDetails<String>() { itemDetails =
object : ItemDetailsLookup.ItemDetails<String>() {
override fun getPosition() = absoluteAdapterPosition override fun getPosition() = absoluteAdapterPosition
override fun getSelectionKey() = item.stableId override fun getSelectionKey() = item.stableId
} }
} }
} }
class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup<String>() {
ItemDetailsLookup<String>() {
override fun getItemDetails(event: MotionEvent): ItemDetails<String>? { override fun getItemDetails(event: MotionEvent): ItemDetails<String>? {
val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null val view = recyclerView.findChildViewUnder(event.x, event.y) ?: return null

View file

@ -54,8 +54,7 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
companion object { companion object {
private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH" private const val EXTRA_FILE_PATH = "dev.msfjarvis.aps.autofill.oreo.EXTRA_FILE_PATH"
private const val EXTRA_SEARCH_ACTION = private const val EXTRA_SEARCH_ACTION = "dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION"
"dev.msfjarvis.aps.autofill.oreo.EXTRA_SEARCH_ACTION"
private var decryptFileRequestCode = 1 private var decryptFileRequestCode = 1
@ -68,26 +67,26 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
} }
fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender { fun makeDecryptFileIntentSender(file: File, context: Context): IntentSender {
val intent = Intent(context, AutofillDecryptActivity::class.java).apply { val intent =
Intent(context, AutofillDecryptActivity::class.java).apply {
putExtra(EXTRA_SEARCH_ACTION, false) putExtra(EXTRA_SEARCH_ACTION, false)
putExtra(EXTRA_FILE_PATH, file.absolutePath) putExtra(EXTRA_FILE_PATH, file.absolutePath)
} }
return PendingIntent.getActivity( return PendingIntent.getActivity(context, decryptFileRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
context, .intentSender
decryptFileRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
).intentSender
} }
} }
private val decryptInteractionRequiredAction = registerForActivityResult(StartIntentSenderForResult()) { result -> private val decryptInteractionRequiredAction =
registerForActivityResult(StartIntentSenderForResult()) { result ->
if (continueAfterUserInteraction != null) { if (continueAfterUserInteraction != null) {
val data = result.data val data = result.data
if (result.resultCode == RESULT_OK && data != null) { if (result.resultCode == RESULT_OK && data != null) {
continueAfterUserInteraction?.resume(data) continueAfterUserInteraction?.resume(data)
} else { } else {
continueAfterUserInteraction?.resumeWithException(Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")) continueAfterUserInteraction?.resumeWithException(
Exception("OpenPgpApi ACTION_DECRYPT_VERIFY failed to continue after user interaction")
)
} }
continueAfterUserInteraction = null continueAfterUserInteraction = null
} }
@ -101,12 +100,16 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
val filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run { val filePath =
intent?.getStringExtra(EXTRA_FILE_PATH)
?: run {
e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" } e { "AutofillDecryptActivity started without EXTRA_FILE_PATH" }
finish() finish()
return return
} }
val clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { val clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
?: run {
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
finish() finish()
return return
@ -121,21 +124,12 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
setResult(RESULT_CANCELED) setResult(RESULT_CANCELED)
} else { } else {
val fillInDataset = val fillInDataset =
AutofillResponseBuilder.makeFillInDataset( AutofillResponseBuilder.makeFillInDataset(this@AutofillDecryptActivity, credentials, clientState, action)
this@AutofillDecryptActivity,
credentials,
clientState,
action
)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
setResult(RESULT_OK, Intent().apply { setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) })
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
})
} }
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) { finish() }
finish()
}
} }
} }
@ -144,14 +138,12 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
coroutineContext.cancelChildren() coroutineContext.cancelChildren()
} }
private suspend fun executeOpenPgpApi( private suspend fun executeOpenPgpApi(data: Intent, input: InputStream, output: OutputStream): Intent? {
data: Intent,
input: InputStream,
output: OutputStream
): Intent? {
var openPgpServiceConnection: OpenPgpServiceConnection? = null var openPgpServiceConnection: OpenPgpServiceConnection? = null
val openPgpService = suspendCoroutine<IOpenPgpService2> { cont -> val openPgpService =
openPgpServiceConnection = OpenPgpServiceConnection( suspendCoroutine<IOpenPgpService2> { cont ->
openPgpServiceConnection =
OpenPgpServiceConnection(
this, this,
OPENPGP_PROVIDER, OPENPGP_PROVIDER,
object : OpenPgpServiceConnection.OnBound { object : OpenPgpServiceConnection.OnBound {
@ -162,59 +154,59 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
override fun onError(e: Exception) { override fun onError(e: Exception) {
cont.resumeWithException(e) cont.resumeWithException(e)
} }
}).also { it.bindToService() } }
)
.also { it.bindToService() }
} }
return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also { return OpenPgpApi(this, openPgpService).executeApi(data, input, output).also {
openPgpServiceConnection?.unbindFromService() openPgpServiceConnection?.unbindFromService()
} }
} }
private suspend fun decryptCredential( private suspend fun decryptCredential(file: File, resumeIntent: Intent? = null): Credentials? {
file: File, val command = resumeIntent ?: Intent().apply { action = OpenPgpApi.ACTION_DECRYPT_VERIFY }
resumeIntent: Intent? = null runCatching { file.inputStream() }
): Credentials? { .onFailure { e ->
val command = resumeIntent ?: Intent().apply {
action = OpenPgpApi.ACTION_DECRYPT_VERIFY
}
runCatching {
file.inputStream()
}.onFailure { e ->
e(e) { "File to decrypt not found" } e(e) { "File to decrypt not found" }
return null return null
}.onSuccess { encryptedInput -> }
.onSuccess { encryptedInput ->
val decryptedOutput = ByteArrayOutputStream() val decryptedOutput = ByteArrayOutputStream()
runCatching { runCatching { executeOpenPgpApi(command, encryptedInput, decryptedOutput) }
executeOpenPgpApi(command, encryptedInput, decryptedOutput) .onFailure { e ->
}.onFailure { e ->
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" } e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed" }
return null return null
}.onSuccess { result -> }
return when (val resultCode = .onSuccess { result ->
result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { return when (val resultCode = result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
OpenPgpApi.RESULT_CODE_SUCCESS -> { OpenPgpApi.RESULT_CODE_SUCCESS -> {
runCatching { runCatching {
val entry = withContext(Dispatchers.IO) { val entry =
@Suppress("BlockingMethodInNonBlockingContext") withContext(Dispatchers.IO) {
(PasswordEntry(decryptedOutput)) @Suppress("BlockingMethodInNonBlockingContext") (PasswordEntry(decryptedOutput))
} }
AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure) AutofillPreferences.credentialsFromStoreEntry(this, file, entry, directoryStructure)
}.getOrElse { e -> }
.getOrElse { e ->
e(e) { "Failed to parse password entry" } e(e) { "Failed to parse password entry" }
return null return null
} }
} }
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
val pendingIntent: PendingIntent = val pendingIntent: PendingIntent = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!
result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!
runCatching { runCatching {
val intentToResume = withContext(Dispatchers.Main) { val intentToResume =
withContext(Dispatchers.Main) {
suspendCoroutine<Intent> { cont -> suspendCoroutine<Intent> { cont ->
continueAfterUserInteraction = cont continueAfterUserInteraction = cont
decryptInteractionRequiredAction.launch(IntentSenderRequest.Builder(pendingIntent.intentSender).build()) decryptInteractionRequiredAction.launch(
IntentSenderRequest.Builder(pendingIntent.intentSender).build()
)
} }
} }
decryptCredential(file, intentToResume) decryptCredential(file, intentToResume)
}.getOrElse { e -> }
.getOrElse { e ->
e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" } e(e) { "OpenPgpApi ACTION_DECRYPT_VERIFY failed with user interaction" }
return null return null
} }
@ -223,11 +215,8 @@ class AutofillDecryptActivity : AppCompatActivity(), CoroutineScope {
val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR) val error = result.getParcelableExtra<OpenPgpError>(OpenPgpApi.RESULT_ERROR)
if (error != null) { if (error != null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
Toast.makeText( Toast.makeText(applicationContext, "Error from OpenKeyChain: ${error.message}", Toast.LENGTH_LONG)
applicationContext, .show()
"Error from OpenKeyChain: ${error.message}",
Toast.LENGTH_LONG
).show()
} }
e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" } e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" }
} }

View file

@ -46,17 +46,13 @@ class AutofillFilterView : AppCompatActivity() {
private const val HEIGHT_PERCENTAGE = 0.9 private const val HEIGHT_PERCENTAGE = 0.9
private const val WIDTH_PERCENTAGE = 0.75 private const val WIDTH_PERCENTAGE = 0.75
private const val EXTRA_FORM_ORIGIN_WEB = private const val EXTRA_FORM_ORIGIN_WEB = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB"
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB" private const val EXTRA_FORM_ORIGIN_APP = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
private const val EXTRA_FORM_ORIGIN_APP =
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
private var matchAndDecryptFileRequestCode = 1 private var matchAndDecryptFileRequestCode = 1
fun makeMatchAndDecryptFileIntentSender( fun makeMatchAndDecryptFileIntentSender(context: Context, formOrigin: FormOrigin): IntentSender {
context: Context, val intent =
formOrigin: FormOrigin Intent(context, AutofillFilterView::class.java).apply {
): IntentSender {
val intent = Intent(context, AutofillFilterView::class.java).apply {
when (formOrigin) { when (formOrigin) {
is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier) is FormOrigin.Web -> putExtra(EXTRA_FORM_ORIGIN_WEB, formOrigin.identifier)
is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier) is FormOrigin.App -> putExtra(EXTRA_FORM_ORIGIN_APP, formOrigin.identifier)
@ -67,7 +63,8 @@ class AutofillFilterView : AppCompatActivity() {
matchAndDecryptFileRequestCode++, matchAndDecryptFileRequestCode++,
intent, intent,
PendingIntent.FLAG_CANCEL_CURRENT PendingIntent.FLAG_CANCEL_CURRENT
).intentSender )
.intentSender
} }
} }
@ -79,7 +76,8 @@ class AutofillFilterView : AppCompatActivity() {
ViewModelProvider.AndroidViewModelFactory(application) ViewModelProvider.AndroidViewModelFactory(application)
} }
private val decryptAction = registerForActivityResult(StartActivityForResult()) { result -> private val decryptAction =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) { if (result.resultCode == RESULT_OK) {
setResult(RESULT_OK, result.data) setResult(RESULT_OK, result.data)
} }
@ -101,7 +99,8 @@ class AutofillFilterView : AppCompatActivity() {
finish() finish()
return return
} }
formOrigin = when { formOrigin =
when {
intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> { intent?.hasExtra(EXTRA_FORM_ORIGIN_WEB) == true -> {
FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!) FormOrigin.Web(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_WEB)!!)
} }
@ -125,19 +124,19 @@ class AutofillFilterView : AppCompatActivity() {
private fun bindUI() { private fun bindUI() {
with(binding) { with(binding) {
rvPassword.apply { rvPassword.apply {
adapter = SearchableRepositoryAdapter( adapter =
R.layout.oreo_autofill_filter_row, SearchableRepositoryAdapter(R.layout.oreo_autofill_filter_row, ::PasswordViewHolder) { item ->
::PasswordViewHolder
) { item ->
val file = item.file.relativeTo(item.rootDir) val file = item.file.relativeTo(item.rootDir)
val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file) val pathToIdentifier = directoryStructure.getPathToIdentifierFor(file)
val identifier = directoryStructure.getIdentifierFor(file) val identifier = directoryStructure.getIdentifierFor(file)
val accountPart = directoryStructure.getAccountPartFor(file) val accountPart = directoryStructure.getAccountPartFor(file)
check(identifier != null || accountPart != null) { "At least one of identifier and accountPart should always be non-null" } check(identifier != null || accountPart != null) {
title.text = if (identifier != null) { "At least one of identifier and accountPart should always be non-null"
}
title.text =
if (identifier != null) {
buildSpannedString { buildSpannedString {
if (pathToIdentifier != null) if (pathToIdentifier != null) append("$pathToIdentifier/")
append("$pathToIdentifier/")
bold { underline { append(identifier) } } bold { underline { append(identifier) } }
} }
} else { } else {
@ -151,38 +150,31 @@ class AutofillFilterView : AppCompatActivity() {
visibility = View.GONE visibility = View.GONE
} }
} }
}.onItemClicked { _, item ->
decryptAndFill(item)
} }
.onItemClicked { _, item -> decryptAndFill(item) }
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
} }
search.apply { search.apply {
val initialSearch = val initialSearch = formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
formOrigin.getPrettyIdentifier(applicationContext, untrusted = false)
setText(initialSearch, TextView.BufferType.EDITABLE) setText(initialSearch, TextView.BufferType.EDITABLE)
addTextChangedListener { updateSearch() } addTextChangedListener { updateSearch() }
} }
origin.text = buildSpannedString { origin.text =
buildSpannedString {
append(getString(R.string.oreo_autofill_select_and_fill_into)) append(getString(R.string.oreo_autofill_select_and_fill_into))
append("\n") append("\n")
bold { bold { append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) }
append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true))
}
} }
strictDomainSearch.apply { strictDomainSearch.apply {
visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE
isChecked = formOrigin is FormOrigin.Web isChecked = formOrigin is FormOrigin.Web
setOnCheckedChangeListener { _, _ -> updateSearch() } setOnCheckedChangeListener { _, _ -> updateSearch() }
} }
shouldMatch.text = getString( shouldMatch.text =
R.string.oreo_autofill_match_with, getString(R.string.oreo_autofill_match_with, formOrigin.getPrettyIdentifier(applicationContext))
formOrigin.getPrettyIdentifier(applicationContext)
)
model.searchResult.observe(this@AutofillFilterView) { result -> model.searchResult.observe(this@AutofillFilterView) { result ->
val list = result.passwordItems val list = result.passwordItems
(rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { (rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) { rvPassword.scrollToPosition(0) }
rvPassword.scrollToPosition(0)
}
// Switch RecyclerView out for a "no results" message if the new list is empty and // Switch RecyclerView out for a "no results" message if the new list is empty and
// the message is not yet shown (and vice versa). // the message is not yet shown (and vice versa).
if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) || if ((list.isEmpty() && rvPasswordSwitcher.nextView.id == rvPasswordEmpty.id) ||
@ -205,16 +197,8 @@ class AutofillFilterView : AppCompatActivity() {
private fun decryptAndFill(item: PasswordItem) { private fun decryptAndFill(item: PasswordItem) {
if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin) if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor( if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file)
applicationContext,
formOrigin,
item.file
)
// intent?.extras? is checked to be non-null in onCreate // intent?.extras? is checked to be non-null in onCreate
decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent( decryptAction.launch(AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this))
item.file,
intent!!.extras!!,
this
))
} }
} }

View file

@ -33,8 +33,7 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
companion object { companion object {
private const val EXTRA_APP_PACKAGE = private const val EXTRA_APP_PACKAGE = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE"
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_APP_PACKAGE"
private const val EXTRA_FILL_RESPONSE_AFTER_RESET = private const val EXTRA_FILL_RESPONSE_AFTER_RESET =
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET" "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FILL_RESPONSE_AFTER_RESET"
private var publisherChangedRequestCode = 1 private var publisherChangedRequestCode = 1
@ -44,13 +43,18 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
publisherChangedException: AutofillPublisherChangedException, publisherChangedException: AutofillPublisherChangedException,
fillResponseAfterReset: FillResponse?, fillResponseAfterReset: FillResponse?,
): IntentSender { ): IntentSender {
val intent = Intent(context, AutofillPublisherChangedActivity::class.java).apply { val intent =
Intent(context, AutofillPublisherChangedActivity::class.java).apply {
putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier) putExtra(EXTRA_APP_PACKAGE, publisherChangedException.formOrigin.identifier)
putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset) putExtra(EXTRA_FILL_RESPONSE_AFTER_RESET, fillResponseAfterReset)
} }
return PendingIntent.getActivity( return PendingIntent.getActivity(
context, publisherChangedRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT context,
).intentSender publisherChangedRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
)
.intentSender
} }
} }
@ -62,7 +66,9 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
setContentView(binding.root) setContentView(binding.root)
setFinishOnTouchOutside(true) setFinishOnTouchOutside(true)
appPackage = intent.getStringExtra(EXTRA_APP_PACKAGE) ?: run { appPackage =
intent.getStringExtra(EXTRA_APP_PACKAGE)
?: run {
e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" } e { "AutofillPublisherChangedActivity started without EXTRA_PACKAGE_NAME" }
finish() finish()
return return
@ -79,9 +85,7 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
resetButton.setOnClickListener { resetButton.setOnClickListener {
AutofillMatcher.clearMatchesFor(this@AutofillPublisherChangedActivity, FormOrigin.App(appPackage)) AutofillMatcher.clearMatchesFor(this@AutofillPublisherChangedActivity, FormOrigin.App(appPackage))
val fillResponse = intent.getParcelableExtra<FillResponse>(EXTRA_FILL_RESPONSE_AFTER_RESET) val fillResponse = intent.getParcelableExtra<FillResponse>(EXTRA_FILL_RESPONSE_AFTER_RESET)
setResult(RESULT_OK, Intent().apply { setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) })
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse)
})
finish() finish()
} }
} }
@ -90,23 +94,18 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
private fun showPackageInfo() { private fun showPackageInfo() {
runCatching { runCatching {
with(binding) { with(binding) {
val packageInfo = val packageInfo = packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime) val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
warningAppInstallDate.text = warningAppInstallDate.text = getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
getString(R.string.oreo_autofill_warning_publisher_install_time, installTime) val appInfo = packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
val appInfo =
packageManager.getApplicationInfo(appPackage, PackageManager.GET_META_DATA)
warningAppName.text = "${packageManager.getApplicationLabel(appInfo)}" warningAppName.text = "${packageManager.getApplicationLabel(appInfo)}"
val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage) val currentHash = computeCertificatesHash(this@AutofillPublisherChangedActivity, appPackage)
warningAppAdvancedInfo.text = getString( warningAppAdvancedInfo.text =
R.string.oreo_autofill_warning_publisher_advanced_info_template, getString(R.string.oreo_autofill_warning_publisher_advanced_info_template, appPackage, currentHash)
appPackage,
currentHash
)
} }
}.onFailure { e -> }
.onFailure { e ->
e(e) { "Failed to retrieve package info for $appPackage" } e(e) { "Failed to retrieve package info for $appPackage" }
finish() finish()
} }

View file

@ -31,37 +31,30 @@ class AutofillSaveActivity : AppCompatActivity() {
companion object { companion object {
private const val EXTRA_FOLDER_NAME = private const val EXTRA_FOLDER_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME"
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME"
private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD" private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD"
private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME" private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME"
private const val EXTRA_SHOULD_MATCH_APP = private const val EXTRA_SHOULD_MATCH_APP = "dev.msfjarvis.aps.autofill.oreo.ui.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_SHOULD_MATCH_WEB = private const val EXTRA_GENERATE_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
private const val EXTRA_GENERATE_PASSWORD =
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
private var saveRequestCode = 1 private var saveRequestCode = 1
fun makeSaveIntentSender( fun makeSaveIntentSender(context: Context, credentials: Credentials?, formOrigin: FormOrigin): IntentSender {
context: Context,
credentials: Credentials?,
formOrigin: FormOrigin
): IntentSender {
val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false) val identifier = formOrigin.getPrettyIdentifier(context, untrusted = false)
// Prevent directory traversals // Prevent directory traversals
val sanitizedIdentifier = identifier.replace('\\', '_') val sanitizedIdentifier =
.replace('/', '_') identifier.replace('\\', '_').replace('/', '_').trimStart('.').takeUnless { it.isBlank() }
.trimStart('.') ?: formOrigin.identifier
.takeUnless { it.isBlank() } ?: formOrigin.identifier
val directoryStructure = AutofillPreferences.directoryStructure(context) val directoryStructure = AutofillPreferences.directoryStructure(context)
val folderName = directoryStructure.getSaveFolderName( val folderName =
directoryStructure.getSaveFolderName(
sanitizedIdentifier = sanitizedIdentifier, sanitizedIdentifier = sanitizedIdentifier,
username = credentials?.username username = credentials?.username
) )
val fileName = directoryStructure.getSaveFileName(username = credentials?.username, identifier = identifier) val fileName = directoryStructure.getSaveFileName(username = credentials?.username, identifier = identifier)
val intent = Intent(context, AutofillSaveActivity::class.java).apply { val intent =
Intent(context, AutofillSaveActivity::class.java).apply {
putExtras( putExtras(
bundleOf( bundleOf(
EXTRA_FOLDER_NAME to folderName, EXTRA_FOLDER_NAME to folderName,
@ -73,12 +66,8 @@ class AutofillSaveActivity : AppCompatActivity() {
) )
) )
} }
return PendingIntent.getActivity( return PendingIntent.getActivity(context, saveRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
context, .intentSender
saveRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
).intentSender
} }
} }
@ -97,7 +86,8 @@ class AutofillSaveActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val repo = PasswordRepository.getRepositoryDirectory() val repo = PasswordRepository.getRepositoryDirectory()
val saveIntent = Intent(this, PasswordCreationActivity::class.java).apply { val saveIntent =
Intent(this, PasswordCreationActivity::class.java).apply {
putExtras( putExtras(
bundleOf( bundleOf(
"REPO_PATH" to repo.absolutePath, "REPO_PATH" to repo.absolutePath,
@ -112,29 +102,23 @@ class AutofillSaveActivity : AppCompatActivity() {
val data = result.data val data = result.data
if (result.resultCode == RESULT_OK && data != null) { if (result.resultCode == RESULT_OK && data != null) {
val createdPath = data.getStringExtra("CREATED_FILE")!! val createdPath = data.getStringExtra("CREATED_FILE")!!
formOrigin?.let { formOrigin?.let { AutofillMatcher.addMatchFor(this, it, File(createdPath)) }
AutofillMatcher.addMatchFor(this, it, File(createdPath))
}
val password = data.getStringExtra("PASSWORD") val password = data.getStringExtra("PASSWORD")
val resultIntent = if (password != null) { val resultIntent =
if (password != null) {
// Password was generated and should be filled into a form. // Password was generated and should be filled into a form.
val username = data.getStringExtra("USERNAME") val username = data.getStringExtra("USERNAME")
val clientState = val clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
?: run {
e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } e { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" }
finish() finish()
return@registerForActivityResult return@registerForActivityResult
} }
val credentials = Credentials(username, password, null) val credentials = Credentials(username, password, null)
val fillInDataset = AutofillResponseBuilder.makeFillInDataset( val fillInDataset =
this, AutofillResponseBuilder.makeFillInDataset(this, credentials, clientState, AutofillAction.Generate)
credentials, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }
clientState,
AutofillAction.Generate
)
Intent().apply {
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
}
} else { } else {
// Password was extracted from a form, there is nothing to fill. // Password was extracted from a form, there is nothing to fill.
Intent() Intent()
@ -144,6 +128,7 @@ class AutofillSaveActivity : AppCompatActivity() {
setResult(RESULT_CANCELED) setResult(RESULT_CANCELED)
} }
finish() finish()
}.launch(saveIntent) }
.launch(saveIntent)
} }
} }

View file

@ -42,14 +42,10 @@ import org.openintents.openpgp.OpenPgpError
@Suppress("Registered") @Suppress("Registered")
open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound { open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBound {
/** /** Full path to the repository */
* Full path to the repository
*/
val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! } val repoPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("REPO_PATH")!! }
/** /** Full path to the password file being worked on */
* Full path to the password file being worked on
*/
val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! } val fullPath by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra("FILE_PATH")!! }
/** /**
@ -59,21 +55,12 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
*/ */
val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension } val name: String by lazy(LazyThreadSafetyMode.NONE) { File(fullPath).nameWithoutExtension }
/** /** Get the timestamp for when this file was last modified. */
* Get the timestamp for when this file was last modified.
*/
val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) { val lastChangedString: CharSequence by lazy(LazyThreadSafetyMode.NONE) {
getLastChangedString( getLastChangedString(intent.getLongExtra("LAST_CHANGED_TIMESTAMP", -1L))
intent.getLongExtra(
"LAST_CHANGED_TIMESTAMP",
-1L
)
)
} }
/** /** [SharedPreferences] instance used by subclasses to persist settings */
* [SharedPreferences] instance used by subclasses to persist settings
*/
val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs } val settings: SharedPreferences by lazy(LazyThreadSafetyMode.NONE) { sharedPrefs }
/** /**
@ -89,8 +76,8 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
private var previousListener: OpenPgpServiceConnection.OnBound? = null private var previousListener: OpenPgpServiceConnection.OnBound? = null
/** /**
* [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or
* or recent apps screen. * recent apps screen.
*/ */
@CallSuper @CallSuper
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@ -100,8 +87,8 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
} }
/** /**
* [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This * [onDestroy] handles unbinding from the OpenPgp service linked with [serviceConnection]. This is
* is annotated with [CallSuper] because it's critical to unbind the service to ensure we're not * annotated with [CallSuper] because it's critical to unbind the service to ensure we're not
* leaking things. * leaking things.
*/ */
@CallSuper @CallSuper
@ -131,21 +118,22 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
/** /**
* Mandatory error handling from [OpenPgpServiceConnection.OnBound]. All subclasses must handle * 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. * their own errors, and hence this class simply logs and rethrows. Subclasses Must NOT call
* super.
*/ */
override fun onError(e: Exception) { override fun onError(e: Exception) {
e(e) { "Callers must handle their own exceptions" } e(e) { "Callers must handle their own exceptions" }
throw e throw e
} }
/** /** Method for subclasses to initiate binding with [OpenPgpServiceConnection]. */
* Method for subclasses to initiate binding with [OpenPgpServiceConnection].
*/
fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) { fun bindToOpenKeychain(onBoundListener: OpenPgpServiceConnection.OnBound) {
val installed = runCatching { val installed =
runCatching {
packageManager.getPackageInfo(OPENPGP_PROVIDER, 0) packageManager.getPackageInfo(OPENPGP_PROVIDER, 0)
true true
}.getOr(false) }
.getOr(false)
if (!installed) { if (!installed) {
previousListener = onBoundListener previousListener = onBoundListener
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
@ -153,7 +141,8 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
.setMessage(getString(R.string.openkeychain_not_installed_message)) .setMessage(getString(R.string.openkeychain_not_installed_message))
.setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ -> .setPositiveButton(getString(R.string.openkeychain_not_installed_google_play)) { _, _ ->
runCatching { runCatching {
val intent = Intent(Intent.ACTION_VIEW).apply { val intent =
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER)) data = Uri.parse(getString(R.string.play_deeplink_template, OPENPGP_PROVIDER))
setPackage("com.android.vending") setPackage("com.android.vending")
} }
@ -162,7 +151,8 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
} }
.setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ -> .setNeutralButton(getString(R.string.openkeychain_not_installed_fdroid)) { _, _ ->
runCatching { runCatching {
val intent = Intent(Intent.ACTION_VIEW).apply { val intent =
Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER)) data = Uri.parse(getString(R.string.fdroid_deeplink_template, OPENPGP_PROVIDER))
} }
startActivity(intent) startActivity(intent)
@ -173,9 +163,7 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
return return
} else { } else {
previousListener = null previousListener = null
serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { it.bindToService() }
it.bindToService()
}
} }
} }
@ -189,10 +177,7 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
return result.getParcelableExtra<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender return result.getParcelableExtra<PendingIntent>(OpenPgpApi.RESULT_INTENT)!!.intentSender
} }
/** /** Gets a relative string describing when this shape was last changed (e.g. "one hour ago") */
* Gets a relative string describing when this shape was last changed
* (e.g. "one hour ago")
*/
private fun getLastChangedString(timeStamp: Long): CharSequence { private fun getLastChangedString(timeStamp: Long): CharSequence {
if (timeStamp < 0) { if (timeStamp < 0) {
throw RuntimeException() throw RuntimeException()
@ -202,8 +187,8 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
} }
/** /**
* Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses * Base handling of OpenKeychain errors based on the error contained in [result]. Subclasses can
* can use this when they want to default to sane error handling. * use this when they want to default to sane error handling.
*/ */
fun handleError(result: Intent) { fun handleError(result: Intent) {
val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR) val error: OpenPgpError? = result.getParcelableExtra(OpenPgpApi.RESULT_ERROR)
@ -242,9 +227,9 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
} }
/** /**
* Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] to hide
* hide the default [Snackbar] and starts off an instance of [ClipboardService] to provide a * the default [Snackbar] and starts off an instance of [ClipboardService] to provide a way of
* way of clearing the clipboard. * clearing the clipboard.
*/ */
fun copyPasswordToClipboard(password: String?) { fun copyPasswordToClipboard(password: String?) {
copyTextToClipboard(password, showSnackbar = false) copyTextToClipboard(password, showSnackbar = false)
@ -252,7 +237,8 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45 val clearAfter = settings.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() ?: 45
if (clearAfter != 0) { if (clearAfter != 0) {
val service = Intent(this, ClipboardService::class.java).apply { val service =
Intent(this, ClipboardService::class.java).apply {
action = ClipboardService.ACTION_START action = ClipboardService.ACTION_START
putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter)
} }
@ -273,24 +259,18 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
const val KEY_PWGEN_TYPE_CLASSIC = "classic" const val KEY_PWGEN_TYPE_CLASSIC = "classic"
const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd" const val KEY_PWGEN_TYPE_XKPASSWD = "xkpasswd"
/** /** Gets the relative path to the repository */
* Gets the relative path to the repository
*/
fun getRelativePath(fullPath: String, repositoryPath: String): String = fun getRelativePath(fullPath: String, repositoryPath: String): String =
fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/")
/** /** Gets the Parent path, relative to the repository */
* Gets the Parent path, relative to the repository
*/
fun getParentPath(fullPath: String, repositoryPath: String): String { fun getParentPath(fullPath: String, repositoryPath: String): String {
val relativePath = getRelativePath(fullPath, repositoryPath) val relativePath = getRelativePath(fullPath, repositoryPath)
val index = relativePath.lastIndexOf("/") val index = relativePath.lastIndexOf("/")
return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/") return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/".replace("/+".toRegex(), "/")
} }
/** /** /path/to/store/social/facebook.gpg -> social/facebook */
* /path/to/store/social/facebook.gpg -> social/facebook
*/
@JvmStatic @JvmStatic
fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { fun getLongName(fullPath: String, repositoryPath: String, basename: String): String {
var relativePath = getRelativePath(fullPath, repositoryPath) var relativePath = getRelativePath(fullPath, repositoryPath)

View file

@ -42,7 +42,8 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) } private val relativeParentPath by lazy(LazyThreadSafetyMode.NONE) { getParentPath(fullPath, repoPath) }
private var passwordEntry: PasswordEntry? = null private var passwordEntry: PasswordEntry? = null
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> private val userInteractionRequiredResult =
registerForActivityResult(StartIntentSenderForResult()) { result ->
if (result.data == null) { if (result.data == null) {
setResult(RESULT_CANCELED, null) setResult(RESULT_CANCELED, null)
finish() finish()
@ -72,9 +73,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
true true
} }
passwordLastChanged.run { passwordLastChanged.run {
runCatching { runCatching { text = resources.getString(R.string.last_changed, lastChangedString) }.onFailure {
text = resources.getString(R.string.last_changed, lastChangedString)
}.onFailure {
visibility = View.GONE visibility = View.GONE
} }
} }
@ -128,8 +127,8 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
} }
/** /**
* Edit the current password and hide all the fields populated by encrypted data so that when * Edit the current password and hide all the fields populated by encrypted data so that when the
* the result triggers they can be repopulated with new data. * result triggers they can be repopulated with new data.
*/ */
private fun editPassword() { private fun editPassword() {
val intent = Intent(this, PasswordCreationActivity::class.java) val intent = Intent(this, PasswordCreationActivity::class.java)
@ -144,7 +143,8 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
} }
private fun shareAsPlaintext() { private fun shareAsPlaintext() {
val sendIntent = Intent().apply { val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, passwordEntry?.password) putExtra(Intent.EXTRA_TEXT, passwordEntry?.password)
type = "text/plain" type = "text/plain"
@ -174,9 +174,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true) val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
val entry = PasswordEntry(outputStream) val entry = PasswordEntry(outputStream)
val items = arrayListOf<FieldItem>() val items = arrayListOf<FieldItem>()
val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> copyTextToClipboard(text) }
copyTextToClipboard(text)
}
if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) { if (settings.getBoolean(PreferenceKeys.COPY_ON_DECRYPT, false)) {
copyPasswordToClipboard(entry.password) copyPasswordToClipboard(entry.password)
@ -193,8 +191,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
// Calculate the actual remaining time for the first pass // Calculate the actual remaining time for the first pass
// then return to the standard 30 second affair. // then return to the standard 30 second affair.
val remainingTime = val remainingTime = entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod)
entry.totpPeriod - (System.currentTimeMillis() % entry.totpPeriod)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val code = entry.calculateTotpCode() ?: "Error" val code = entry.calculateTotpCode() ?: "Error"
items.add(FieldItem.createOtpField(code)) items.add(FieldItem.createOtpField(code))
@ -202,9 +199,7 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
delay(remainingTime.seconds) delay(remainingTime.seconds)
repeat(Int.MAX_VALUE) { repeat(Int.MAX_VALUE) {
val code = entry.calculateTotpCode() ?: "Error" val code = entry.calculateTotpCode() ?: "Error"
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) { adapter.updateOTPCode(code) }
adapter.updateOTPCode(code)
}
delay(30.seconds) delay(30.seconds)
} }
} }
@ -222,9 +217,8 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
adapter.updateItems(items) adapter.updateItems(items)
}.onFailure { e ->
e(e)
} }
.onFailure { e -> e(e) }
} }
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
val sender = getUserInteractionRequestIntent(result) val sender = getUserInteractionRequestIntent(result)

View file

@ -21,7 +21,8 @@ import org.openintents.openpgp.IOpenPgpService2
class GetKeyIdsActivity : BasePgpActivity() { class GetKeyIdsActivity : BasePgpActivity() {
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> private val userInteractionRequiredResult =
registerForActivityResult(StartIntentSenderForResult()) { result ->
if (result.data == null || result.resultCode == RESULT_CANCELED) { if (result.data == null || result.resultCode == RESULT_CANCELED) {
setResult(RESULT_CANCELED, result.data) setResult(RESULT_CANCELED, result.data)
finish() finish()
@ -44,9 +45,7 @@ class GetKeyIdsActivity : BasePgpActivity() {
e(e) e(e)
} }
/** /** Get the Key ids from OpenKeychain */
* Get the Key ids from OpenKeychain
*/
private fun getKeyIds(data: Intent = Intent()) { private fun getKeyIds(data: Intent = Intent()) {
data.action = OpenPgpApi.ACTION_GET_KEY_IDS data.action = OpenPgpApi.ACTION_GET_KEY_IDS
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
@ -54,15 +53,14 @@ class GetKeyIdsActivity : BasePgpActivity() {
when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) { when (result?.getIntExtra(OpenPgpApi.RESULT_CODE, OpenPgpApi.RESULT_CODE_ERROR)) {
OpenPgpApi.RESULT_CODE_SUCCESS -> { OpenPgpApi.RESULT_CODE_SUCCESS -> {
runCatching { runCatching {
val ids = result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { val ids =
OpenPgpUtils.convertKeyIdToHex(it) result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { OpenPgpUtils.convertKeyIdToHex(it) }
} ?: emptyList() ?: emptyList()
val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray()) val keyResult = Intent().putExtra(OpenPgpApi.EXTRA_KEY_IDS, ids.toTypedArray())
setResult(RESULT_OK, keyResult) setResult(RESULT_OK, keyResult)
finish() finish()
}.onFailure { e ->
e(e)
} }
.onFailure { e -> e(e) }
} }
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
val sender = getUserInteractionRequestIntent(result) val sender = getUserInteractionRequestIntent(result)

View file

@ -60,14 +60,17 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) } private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) } private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) } private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false) } private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) {
intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
}
private val editing by lazy(LazyThreadSafetyMode.NONE) { intent.getBooleanExtra(EXTRA_EDITING, 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 val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
private var oldCategory: String? = null private var oldCategory: String? = null
private var copy: Boolean = false private var copy: Boolean = false
private var encryptionIntent: Intent = Intent() private var encryptionIntent: Intent = Intent()
private val userInteractionRequiredResult = registerForActivityResult(StartIntentSenderForResult()) { result -> private val userInteractionRequiredResult =
registerForActivityResult(StartIntentSenderForResult()) { result ->
if (result.data == null) { if (result.data == null) {
setResult(RESULT_CANCELED, null) setResult(RESULT_CANCELED, null)
finish() finish()
@ -83,36 +86,35 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
} }
} }
private val otpImportAction = registerForActivityResult(StartActivityForResult()) { result -> private val otpImportAction =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) { if (result.resultCode == RESULT_OK) {
binding.otpImportButton.isVisible = false binding.otpImportButton.isVisible = false
val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data) val intentResult = IntentIntegrator.parseActivityResult(RESULT_OK, result.data)
val contents = "${intentResult.contents}\n" val contents = "${intentResult.contents}\n"
val currentExtras = binding.extraContent.text.toString() val currentExtras = binding.extraContent.text.toString()
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') binding.extraContent.append("\n$contents")
binding.extraContent.append("\n$contents") else binding.extraContent.append(contents)
else
binding.extraContent.append(contents)
snackbar(message = getString(R.string.otp_import_success)) snackbar(message = getString(R.string.otp_import_success))
} else { } else {
snackbar(message = getString(R.string.otp_import_failure)) snackbar(message = getString(R.string.otp_import_failure))
} }
} }
private val gpgKeySelectAction = registerForActivityResult(StartActivityForResult()) { result -> private val gpgKeySelectAction =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) { if (result.resultCode == RESULT_OK) {
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
lifecycleScope.launch { lifecycleScope.launch {
val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id") val gpgIdentifierFile = File(PasswordRepository.getRepositoryDirectory(), ".gpg-id")
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) }
gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) commitChange(
} getString(
commitChange(getString(
R.string.git_commit_gpg_id, R.string.git_commit_gpg_id,
getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name)
)).onSuccess { )
encrypt(encryptionIntent) )
} .onSuccess { encrypt(encryptionIntent) }
} }
} }
} }
@ -138,33 +140,32 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
bindToOpenKeychain(this) bindToOpenKeychain(this)
title = if (editing) title = if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title)
getString(R.string.edit_password)
else
getString(R.string.new_password_title)
with(binding) { with(binding) {
setContentView(root) setContentView(root)
generatePassword.setOnClickListener { generatePassword() } generatePassword.setOnClickListener { generatePassword() }
otpImportButton.setOnClickListener { otpImportButton.setOnClickListener {
supportFragmentManager.setFragmentResultListener(OTP_RESULT_REQUEST_KEY, this@PasswordCreationActivity) { requestKey, bundle -> supportFragmentManager.setFragmentResultListener(OTP_RESULT_REQUEST_KEY, this@PasswordCreationActivity) {
requestKey,
bundle ->
if (requestKey == OTP_RESULT_REQUEST_KEY) { if (requestKey == OTP_RESULT_REQUEST_KEY) {
val contents = bundle.getString(RESULT) val contents = bundle.getString(RESULT)
val currentExtras = binding.extraContent.text.toString() val currentExtras = binding.extraContent.text.toString()
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') if (currentExtras.isNotEmpty() && currentExtras.last() != '\n') binding.extraContent.append("\n$contents")
binding.extraContent.append("\n$contents") else binding.extraContent.append(contents)
else
binding.extraContent.append(contents)
} }
} }
val items = arrayOf(getString(R.string.otp_import_qr_code), getString(R.string.otp_import_manual_entry)) val items = arrayOf(getString(R.string.otp_import_qr_code), getString(R.string.otp_import_manual_entry))
MaterialAlertDialogBuilder(this@PasswordCreationActivity) MaterialAlertDialogBuilder(this@PasswordCreationActivity)
.setItems(items) { _, index -> .setItems(items) { _, index ->
if (index == 0) { if (index == 0) {
otpImportAction.launch(IntentIntegrator(this@PasswordCreationActivity) otpImportAction.launch(
IntentIntegrator(this@PasswordCreationActivity)
.setOrientationLocked(false) .setOrientationLocked(false)
.setBeepEnabled(false) .setBeepEnabled(false)
.setDesiredBarcodeFormats(QR_CODE) .setDesiredBarcodeFormats(QR_CODE)
.createScanIntent()) .createScanIntent()
)
} else if (index == 1) { } else if (index == 1) {
OtpImportDialogFragment().show(supportFragmentManager, "OtpImport") OtpImportDialogFragment().show(supportFragmentManager, "OtpImport")
} }
@ -180,8 +181,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
} }
val path = getRelativePath(fullPath, repoPath) val path = getRelativePath(fullPath, repoPath)
// Keep empty path field visible if it is editable. // Keep empty path field visible if it is editable.
if (path.isEmpty() && !isEnabled) if (path.isEmpty() && !isEnabled) visibility = View.GONE
visibility = View.GONE
else { else {
directory.setText(path) directory.setText(path)
oldCategory = path oldCategory = path
@ -196,8 +196,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
// in the encrypted extras. This only makes sense if the directory structure is // in the encrypted extras. This only makes sense if the directory structure is
// FileBased. // FileBased.
if (suggestedName == null && if (suggestedName == null &&
AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == AutofillPreferences.directoryStructure(this@PasswordCreationActivity) == DirectoryStructure.FileBased
DirectoryStructure.FileBased
) { ) {
encryptUsername.apply { encryptUsername.apply {
visibility = View.VISIBLE visibility = View.VISIBLE
@ -226,9 +225,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
} }
} }
} }
listOf(filename, extraContent).forEach { listOf(filename, extraContent).forEach { it.doOnTextChanged { _, _, _, _ -> updateViewState() } }
it.doOnTextChanged { _, _, _, _ -> updateViewState() }
}
} }
suggestedPass?.let { suggestedPass?.let {
password.setText(it) password.setText(it)
@ -274,19 +271,17 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
} }
} }
when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) { when (settings.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) ?: KEY_PWGEN_TYPE_CLASSIC) {
KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment() KEY_PWGEN_TYPE_CLASSIC -> PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
.show(supportFragmentManager, "generator") KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment().show(supportFragmentManager, "xkpwgenerator")
KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment()
.show(supportFragmentManager, "xkpwgenerator")
} }
} }
private fun updateViewState() = with(binding) { private fun updateViewState() =
with(binding) {
// Use PasswordEntry to parse extras for username // Use PasswordEntry to parse extras for username
val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}") val entry = PasswordEntry("PLACEHOLDER\n${extraContent.text}")
encryptUsername.apply { encryptUsername.apply {
if (visibility != View.VISIBLE) if (visibility != View.VISIBLE) return@apply
return@apply
val hasUsernameInFileName = filename.text.toString().isNotBlank() val hasUsernameInFileName = filename.text.toString().isNotBlank()
val hasUsernameInExtras = entry.hasUsername() val hasUsernameInExtras = entry.hasUsername()
isEnabled = hasUsernameInFileName xor hasUsernameInExtras isEnabled = hasUsernameInFileName xor hasUsernameInExtras
@ -295,9 +290,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
otpImportButton.isVisible = !entry.hasTotp() otpImportButton.isVisible = !entry.hasTotp()
} }
/** /** Encrypts the password and the extra content */
* Encrypts the password and the extra content
*/
private fun encrypt(receivedIntent: Intent? = null) { private fun encrypt(receivedIntent: Intent? = null) {
with(binding) { with(binding) {
val editName = filename.text.toString().trim() val editName = filename.text.toString().trim()
@ -326,14 +319,17 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
// pass enters the key ID into `.gpg-id`. // pass enters the key ID into `.gpg-id`.
val repoRoot = PasswordRepository.getRepositoryDirectory() val repoRoot = PasswordRepository.getRepositoryDirectory()
val gpgIdentifierFile = File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot) val gpgIdentifierFile =
File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot)
?: File(repoRoot, ".gpg-id").apply { createNewFile() } ?: File(repoRoot, ".gpg-id").apply { createNewFile() }
val gpgIdentifiers = gpgIdentifierFile.readLines() val gpgIdentifiers =
.filter { it.isNotBlank() } gpgIdentifierFile.readLines().filter { it.isNotBlank() }.map { line ->
.map { line -> GpgIdentifier.fromString(line)
GpgIdentifier.fromString(line) ?: run { ?: run {
// The line being empty means this is most likely an empty `.gpg-id` file // The line being empty means this is most likely an empty `.gpg-id`
// we created. Skip the validation so we can make the user add a real ID. // file
// we created. Skip the validation so we can make the user add a real
// ID.
if (line.isEmpty()) return@run if (line.isEmpty()) return@run
if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) {
snackbar(message = resources.getString(R.string.short_key_ids_unsupported)) snackbar(message = resources.getString(R.string.short_key_ids_unsupported))
@ -362,8 +358,10 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
val inputStream = ByteArrayInputStream(content.toByteArray()) val inputStream = ByteArrayInputStream(content.toByteArray())
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
val path = when { val path =
// If we allowed the user to edit the relative path, we have to consider it here instead when {
// If we allowed the user to edit the relative path, we have to consider it here
// instead
// of fullPath. // of fullPath.
directoryInputLayout.isEnabled -> { directoryInputLayout.isEnabled -> {
val editRelativePath = directory.text.toString().trim() val editRelativePath = directory.text.toString().trim()
@ -402,11 +400,10 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
return@executeApiAsync return@executeApiAsync
} }
file.outputStream().use { file.outputStream().use { it.write(outputStream.toByteArray()) }
it.write(outputStream.toByteArray())
}
//associate the new password name with the last name's timestamp in history // associate the new password name with the last name's timestamp in
// history
val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64() val oldFilePathHash = "$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg".base64()
val timestamp = preference.getString(oldFilePathHash) val timestamp = preference.getString(oldFilePathHash)
@ -423,12 +420,10 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName)) returnIntent.putExtra(RETURN_EXTRA_LONG_NAME, getLongName(fullPath, repoPath, editName))
if (shouldGeneratePassword) { if (shouldGeneratePassword) {
val directoryStructure = val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
AutofillPreferences.directoryStructure(applicationContext)
val entry = PasswordEntry(content) val entry = PasswordEntry(content)
returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password) returnIntent.putExtra(RETURN_EXTRA_PASSWORD, entry.password)
val username = entry.username val username = entry.username ?: directoryStructure.getUsernameFor(file)
?: directoryStructure.getUsernameFor(file)
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username) returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
} }
@ -440,9 +435,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
.setTitle(R.string.password_creation_file_fail_title) .setTitle(R.string.password_creation_file_fail_title)
.setMessage(getString(R.string.password_creation_file_delete_fail_message, oldFileName)) .setMessage(getString(R.string.password_creation_file_delete_fail_message, oldFileName))
.setCancelable(false) .setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
finish()
}
.show() .show()
return@executeApiAsync return@executeApiAsync
} }
@ -450,16 +443,14 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text val commitMessageRes = if (editing) R.string.git_commit_edit_text else R.string.git_commit_add_text
lifecycleScope.launch { lifecycleScope.launch {
commitChange(resources.getString( commitChange(resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName)))
commitMessageRes, .onSuccess {
getLongName(fullPath, repoPath, editName)
)).onSuccess {
setResult(RESULT_OK, returnIntent) setResult(RESULT_OK, returnIntent)
finish() finish()
} }
} }
}
}.onFailure { e -> .onFailure { e ->
if (e is IOException) { if (e is IOException) {
e(e) { "Failed to write password file" } e(e) { "Failed to write password file" }
setResult(RESULT_CANCELED) setResult(RESULT_CANCELED)
@ -467,9 +458,7 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
.setTitle(getString(R.string.password_creation_file_fail_title)) .setTitle(getString(R.string.password_creation_file_fail_title))
.setMessage(getString(R.string.password_creation_file_write_fail_message)) .setMessage(getString(R.string.password_creation_file_write_fail_message))
.setCancelable(false) .setCancelable(false)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ -> finish() }
finish()
}
.show() .show()
} else { } else {
e(e) e(e)

View file

@ -25,10 +25,11 @@ import dev.msfjarvis.aps.util.extensions.resolveAttribute
import dev.msfjarvis.aps.util.extensions.viewBinding import dev.msfjarvis.aps.util.extensions.viewBinding
/** /**
* [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like * [BottomSheetDialogFragment] that exposes a simple [androidx.appcompat.app.AlertDialog] like API
* API through [Builder] to create a similar UI, just at the bottom of the screen. * through [Builder] to create a similar UI, just at the bottom of the screen.
*/ */
class BasicBottomSheet private constructor( class BasicBottomSheet
private constructor(
val title: String?, val title: String?,
val message: String, val message: String,
val positiveButtonLabel: String?, val positiveButtonLabel: String?,
@ -40,9 +41,9 @@ class BasicBottomSheet private constructor(
private val binding by viewBinding(BasicBottomSheetBinding::bind) private val binding by viewBinding(BasicBottomSheetBinding::bind)
private var behavior: BottomSheetBehavior<FrameLayout>? = null private var behavior: BottomSheetBehavior<FrameLayout>? = null
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { private val bottomSheetCallback =
override fun onSlide(bottomSheet: View, slideOffset: Float) { object : BottomSheetBehavior.BottomSheetCallback() {
} override fun onSlide(bottomSheet: View, slideOffset: Float) {}
override fun onStateChanged(bottomSheet: View, newState: Int) { override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) { if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
@ -58,7 +59,8 @@ class BasicBottomSheet private constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { view.viewTreeObserver.addOnGlobalLayoutListener(
object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() { override fun onGlobalLayout() {
view.viewTreeObserver.removeOnGlobalLayoutListener(this) view.viewTreeObserver.removeOnGlobalLayoutListener(this)
val dialog = dialog as BottomSheetDialog? ?: return val dialog = dialog as BottomSheetDialog? ?: return
@ -74,9 +76,7 @@ class BasicBottomSheet private constructor(
} }
binding.bottomSheetMessage.text = message binding.bottomSheetMessage.text = message
if (positiveButtonClickListener != null) { if (positiveButtonClickListener != null) {
positiveButtonLabel?.let { buttonLbl -> positiveButtonLabel?.let { buttonLbl -> binding.bottomSheetOkButton.text = buttonLbl }
binding.bottomSheetOkButton.text = buttonLbl
}
binding.bottomSheetOkButton.isVisible = true binding.bottomSheetOkButton.isVisible = true
binding.bottomSheetOkButton.setOnClickListener { binding.bottomSheetOkButton.setOnClickListener {
positiveButtonClickListener.onClick(it) positiveButtonClickListener.onClick(it)
@ -85,19 +85,17 @@ class BasicBottomSheet private constructor(
} }
if (negativeButtonClickListener != null) { if (negativeButtonClickListener != null) {
binding.bottomSheetCancelButton.isVisible = true binding.bottomSheetCancelButton.isVisible = true
negativeButtonLabel?.let { buttonLbl -> negativeButtonLabel?.let { buttonLbl -> binding.bottomSheetCancelButton.text = buttonLbl }
binding.bottomSheetCancelButton.text = buttonLbl
}
binding.bottomSheetCancelButton.setOnClickListener { binding.bottomSheetCancelButton.setOnClickListener {
negativeButtonClickListener.onClick(it) negativeButtonClickListener.onClick(it)
dismiss() dismiss()
} }
} }
} }
})
val gradientDrawable = GradientDrawable().apply {
setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
} }
)
val gradientDrawable =
GradientDrawable().apply { setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) }
view.background = gradientDrawable view.background = gradientDrawable
} }

View file

@ -32,7 +32,8 @@ class FolderCreationDialogFragment : DialogFragment() {
private lateinit var newFolder: File private lateinit var newFolder: File
private val keySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val keySelectAction =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) { if (result.resultCode == AppCompatActivity.RESULT_OK) {
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
val gpgIdentifierFile = File(newFolder, ".gpg-id") val gpgIdentifierFile = File(newFolder, ".gpg-id")
@ -41,10 +42,15 @@ class FolderCreationDialogFragment : DialogFragment() {
if (repo != null) { if (repo != null) {
lifecycleScope.launch { lifecycleScope.launch {
val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath val repoPath = PasswordRepository.getRepositoryDirectory().absolutePath
requireActivity().commitChange( requireActivity()
.commitChange(
getString( getString(
R.string.git_commit_gpg_id, R.string.git_commit_gpg_id,
BasePgpActivity.getLongName(gpgIdentifierFile.parentFile!!.absolutePath, repoPath, gpgIdentifierFile.name) BasePgpActivity.getLongName(
gpgIdentifierFile.parentFile!!.absolutePath,
repoPath,
gpgIdentifierFile.name
)
), ),
) )
dismiss() dismiss()
@ -59,9 +65,7 @@ class FolderCreationDialogFragment : DialogFragment() {
alertDialogBuilder.setTitle(R.string.title_create_folder) alertDialogBuilder.setTitle(R.string.title_create_folder)
alertDialogBuilder.setView(R.layout.folder_dialog_fragment) alertDialogBuilder.setView(R.layout.folder_dialog_fragment)
alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null) alertDialogBuilder.setPositiveButton(getString(R.string.button_create), null)
alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> alertDialogBuilder.setNegativeButton(getString(android.R.string.cancel)) { _, _ -> dismiss() }
dismiss()
}
val dialog = alertDialogBuilder.create() val dialog = alertDialogBuilder.create()
dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text) dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
dialog.setOnShowListener { dialog.setOnShowListener {
@ -77,7 +81,8 @@ class FolderCreationDialogFragment : DialogFragment() {
val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text) val folderNameView = dialog.findViewById<TextInputEditText>(R.id.folder_name_text)
val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container) val folderNameViewContainer = dialog.findViewById<TextInputLayout>(R.id.folder_name_container)
newFolder = File("$currentDir/${folderNameView.text}") newFolder = File("$currentDir/${folderNameView.text}")
folderNameViewContainer.error = when { folderNameViewContainer.error =
when {
newFolder.isFile -> getString(R.string.folder_creation_err_file_exists) newFolder.isFile -> getString(R.string.folder_creation_err_file_exists)
newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists) newFolder.isDirectory -> getString(R.string.folder_creation_err_folder_exists)
else -> null else -> null

View file

@ -26,9 +26,9 @@ import dev.msfjarvis.aps.util.extensions.resolveAttribute
class ItemCreationBottomSheet : BottomSheetDialogFragment() { class ItemCreationBottomSheet : BottomSheetDialogFragment() {
private var behavior: BottomSheetBehavior<FrameLayout>? = null private var behavior: BottomSheetBehavior<FrameLayout>? = null
private val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { private val bottomSheetCallback =
override fun onSlide(bottomSheet: View, slideOffset: Float) { object : BottomSheetBehavior.BottomSheetCallback() {
} override fun onSlide(bottomSheet: View, slideOffset: Float) {}
override fun onStateChanged(bottomSheet: View, newState: Int) { override fun onStateChanged(bottomSheet: View, newState: Int) {
if (newState == BottomSheetBehavior.STATE_COLLAPSED) { if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
@ -44,7 +44,8 @@ class ItemCreationBottomSheet : BottomSheetDialogFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
view.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { view.viewTreeObserver.addOnGlobalLayoutListener(
object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() { override fun onGlobalLayout() {
view.viewTreeObserver.removeOnGlobalLayoutListener(this) view.viewTreeObserver.removeOnGlobalLayoutListener(this)
val dialog = dialog as BottomSheetDialog? ?: return val dialog = dialog as BottomSheetDialog? ?: return
@ -63,10 +64,10 @@ class ItemCreationBottomSheet : BottomSheetDialogFragment() {
dismiss() dismiss()
} }
} }
})
val gradientDrawable = GradientDrawable().apply {
setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
} }
)
val gradientDrawable =
GradientDrawable().apply { setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) }
view.background = gradientDrawable view.background = gradientDrawable
} }

View file

@ -27,9 +27,7 @@ class OtpImportDialogFragment : DialogFragment() {
builder.setPositiveButton(android.R.string.ok) { _, _ -> builder.setPositiveButton(android.R.string.ok) { _, _ ->
setFragmentResult( setFragmentResult(
PasswordCreationActivity.OTP_RESULT_REQUEST_KEY, PasswordCreationActivity.OTP_RESULT_REQUEST_KEY,
bundleOf( bundleOf(PasswordCreationActivity.RESULT to getTOTPUri(binding))
PasswordCreationActivity.RESULT to getTOTPUri(binding)
)
) )
} }
val dialog = builder.create() val dialog = builder.create()

View file

@ -36,8 +36,7 @@ class PasswordGeneratorDialogFragment : DialogFragment() {
val callingActivity = requireActivity() val callingActivity = requireActivity()
val binding = FragmentPwgenBinding.inflate(layoutInflater) val binding = FragmentPwgenBinding.inflate(layoutInflater)
val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf") val monoTypeface = Typeface.createFromAsset(callingActivity.assets, "fonts/sourcecodepro.ttf")
val prefs = requireActivity().applicationContext val prefs = requireActivity().applicationContext.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
builder.setView(binding.root) builder.setView(binding.root)
@ -50,7 +49,8 @@ class PasswordGeneratorDialogFragment : DialogFragment() {
binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString()) binding.lengthNumber.setText(prefs.getInt(PreferenceKeys.LENGTH, 20).toString())
binding.passwordText.typeface = monoTypeface binding.passwordText.typeface = monoTypeface
return builder.run { return builder
.run {
setTitle(R.string.pwgen_title) setTitle(R.string.pwgen_title)
setPositiveButton(R.string.dialog_ok) { _, _ -> setPositiveButton(R.string.dialog_ok) { _, _ ->
setFragmentResult( setFragmentResult(
@ -61,21 +61,19 @@ class PasswordGeneratorDialogFragment : DialogFragment() {
setNeutralButton(R.string.dialog_cancel) { _, _ -> } setNeutralButton(R.string.dialog_cancel) { _, _ -> }
setNegativeButton(R.string.pwgen_generate, null) setNegativeButton(R.string.pwgen_generate, null)
create() create()
}.apply { }
.apply {
setOnShowListener { setOnShowListener {
generate(binding.passwordText) generate(binding.passwordText)
getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generate(binding.passwordText) }
generate(binding.passwordText)
}
} }
} }
} }
private fun generate(passwordField: AppCompatTextView) { private fun generate(passwordField: AppCompatTextView) {
setPreferences() setPreferences()
passwordField.text = runCatching { passwordField.text =
generate(requireContext().applicationContext) runCatching { generate(requireContext().applicationContext) }.getOrElse { e ->
}.getOrElse { e ->
Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show() Toast.makeText(requireActivity(), e.message, Toast.LENGTH_SHORT).show()
"" ""
} }
@ -86,7 +84,8 @@ class PasswordGeneratorDialogFragment : DialogFragment() {
} }
private fun setPreferences() { private fun setPreferences() {
val preferences = listOfNotNull( val preferences =
listOfNotNull(
PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) }, PasswordOption.NoDigits.takeIf { !isChecked(R.id.numerals) },
PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) }, PasswordOption.AtLeastOneSymbol.takeIf { isChecked(R.id.symbols) },
PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) }, PasswordOption.NoUppercaseLetters.takeIf { !isChecked(R.id.uppercase) },
@ -95,8 +94,7 @@ class PasswordGeneratorDialogFragment : DialogFragment() {
PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) } PasswordOption.NoLowercaseLetters.takeIf { !isChecked(R.id.lowercase) }
) )
val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString() val lengthText = requireDialog().findViewById<EditText>(R.id.lengthNumber).text.toString()
val length = lengthText.toIntOrNull()?.takeIf { it >= 0 } val length = lengthText.toIntOrNull()?.takeIf { it >= 0 } ?: PasswordGenerator.DEFAULT_LENGTH
?: PasswordGenerator.DEFAULT_LENGTH
setPrefs(requireActivity().applicationContext, preferences, length) setPrefs(requireActivity().applicationContext, preferences, length)
} }
} }

View file

@ -40,13 +40,11 @@ class XkPasswordGeneratorDialogFragment : DialogFragment() {
builder.setView(binding.root) builder.setView(binding.root)
val previousStoredCapStyle: String = runCatching { val previousStoredCapStyle: String =
prefs.getString(PREF_KEY_CAPITALS_STYLE)!! runCatching { prefs.getString(PREF_KEY_CAPITALS_STYLE)!! }.getOr(DEFAULT_CAPS_STYLE)
}.getOr(DEFAULT_CAPS_STYLE)
val lastCapitalsStyleIndex: Int = runCatching { val lastCapitalsStyleIndex: Int =
CapsType.valueOf(previousStoredCapStyle).ordinal runCatching { CapsType.valueOf(previousStoredCapStyle).ordinal }.getOr(DEFAULT_CAPS_INDEX)
}.getOr(DEFAULT_CAPS_INDEX)
binding.xkCapType.setSelection(lastCapitalsStyleIndex) binding.xkCapType.setSelection(lastCapitalsStyleIndex)
binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS)) binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS))
@ -89,7 +87,8 @@ class XkPasswordGeneratorDialogFragment : DialogFragment() {
.setSeparator(binding.xkSeparator.text.toString()) .setSeparator(binding.xkSeparator.text.toString())
.appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT }) .appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT })
.appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL }) .appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL })
.setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString())).create() .setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString()))
.create()
.fold( .fold(
success = { binding.xkPasswordText.text = it }, success = { binding.xkPasswordText.text = it },
failure = { e -> failure = { e ->

View file

@ -15,7 +15,6 @@ import dev.msfjarvis.aps.data.repo.PasswordRepository
import dev.msfjarvis.aps.ui.passwords.PASSWORD_FRAGMENT_TAG import dev.msfjarvis.aps.ui.passwords.PASSWORD_FRAGMENT_TAG
import dev.msfjarvis.aps.ui.passwords.PasswordStore import dev.msfjarvis.aps.ui.passwords.PasswordStore
class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) { class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
private lateinit var passwordList: SelectFolderFragment private lateinit var passwordList: SelectFolderFragment
@ -33,9 +32,7 @@ class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE)
supportFragmentManager.commit { supportFragmentManager.commit { replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) }
replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG)
}
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {

View file

@ -35,10 +35,7 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.fab.hide() binding.fab.hide()
recyclerAdapter = PasswordItemRecyclerAdapter() recyclerAdapter = PasswordItemRecyclerAdapter().onItemClicked { _, item -> listener.onFragmentInteraction(item) }
.onItemClicked { _, item ->
listener.onFragmentInteraction(item)
}
binding.passRecycler.apply { binding.passRecycler.apply {
layoutManager = LinearLayoutManager(requireContext()) layoutManager = LinearLayoutManager(requireContext())
itemAnimator = null itemAnimator = null
@ -50,15 +47,14 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH)) val path = requireNotNull(requireArguments().getString(PasswordStore.REQUEST_ARG_PATH))
model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false) model.navigateTo(File(path), listMode = ListMode.DirectoriesOnly, pushPreviousLocation = false)
model.searchResult.observe(viewLifecycleOwner) { result -> model.searchResult.observe(viewLifecycleOwner) { result -> recyclerAdapter.submitList(result.passwordItems) }
recyclerAdapter.submitList(result.passwordItems)
}
} }
override fun onAttach(context: Context) { override fun onAttach(context: Context) {
super.onAttach(context) super.onAttach(context)
runCatching { runCatching {
listener = object : OnFragmentInteractionListener { listener =
object : OnFragmentInteractionListener {
override fun onFragmentInteraction(item: PasswordItem) { override fun onFragmentInteraction(item: PasswordItem) {
if (item.type == PasswordItem.TYPE_CATEGORY) { if (item.type == PasswordItem.TYPE_CATEGORY) {
model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly) model.navigateTo(item.file, listMode = ListMode.DirectoriesOnly)
@ -66,9 +62,8 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
} }
} }
} }
}.onFailure {
throw ClassCastException("$context must implement OnFragmentInteractionListener")
} }
.onFailure { throw ClassCastException("$context must implement OnFragmentInteractionListener") }
} }
val currentDir: File val currentDir: File

View file

@ -33,16 +33,13 @@ import net.schmizz.sshj.transport.TransportException
import net.schmizz.sshj.userauth.UserAuthException import net.schmizz.sshj.userauth.UserAuthException
/** /**
* Abstract [AppCompatActivity] that holds some information that is commonly shared across git-related * Abstract [AppCompatActivity] that holds some information that is commonly shared across
* tasks and makes sense to be held here. * git-related tasks and makes sense to be held here.
*/ */
abstract class BaseGitActivity : ContinuationContainerActivity() { abstract class BaseGitActivity : ContinuationContainerActivity() {
/** /** Enum of possible Git operations than can be run through [launchGitOperation]. */
* Enum of possible Git operations than can be run through [launchGitOperation].
*/
enum class GitOp { enum class GitOp {
BREAK_OUT_OF_DETACHED, BREAK_OUT_OF_DETACHED,
CLONE, CLONE,
PULL, PULL,
@ -64,7 +61,8 @@ abstract class BaseGitActivity : ContinuationContainerActivity() {
// a sync operation without reconnecting and thus break sync into its two parts. // a sync operation without reconnecting and thus break sync into its two parts.
return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) } return launchGitOperation(GitOp.PULL).andThen { launchGitOperation(GitOp.PUSH) }
} }
val op = when (operation) { val op =
when (operation) {
GitOp.CLONE -> CloneOperation(this, GitSettings.url!!) GitOp.CLONE -> CloneOperation(this, GitSettings.url!!)
GitOp.PULL -> PullOperation(this, GitSettings.rebaseOnPull) GitOp.PULL -> PullOperation(this, GitSettings.rebaseOnPull)
GitOp.PUSH -> PushOperation(this) GitOp.PUSH -> PushOperation(this)
@ -82,9 +80,7 @@ abstract class BaseGitActivity : ContinuationContainerActivity() {
suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) { suspend fun promptOnErrorHandler(err: Throwable, onPromptDone: () -> Unit = {}) {
val error = rootCauseException(err) val error = rootCauseException(err)
if (!isExplicitlyUserInitiatedError(error)) { if (!isExplicitlyUserInitiatedError(error)) {
getEncryptedGitPrefs().edit { getEncryptedGitPrefs().edit { remove(PreferenceKeys.HTTPS_PASSWORD) }
remove(PreferenceKeys.HTTPS_PASSWORD)
}
sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) }
d(error) d(error)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -92,9 +88,7 @@ abstract class BaseGitActivity : ContinuationContainerActivity() {
setTitle(resources.getString(R.string.jgit_error_dialog_title)) setTitle(resources.getString(R.string.jgit_error_dialog_title))
setMessage(ErrorMessages[error]) setMessage(ErrorMessages[error])
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
setOnDismissListener { setOnDismissListener { onPromptDone() }
onPromptDone()
}
show() show()
} }
} }
@ -104,21 +98,27 @@ abstract class BaseGitActivity : ContinuationContainerActivity() {
} }
/** /**
* Takes the result of [launchGitOperation] and applies any necessary transformations * Takes the result of [launchGitOperation] and applies any necessary transformations on the
* on the [throwable] returned from it * [throwable] returned from it
*/ */
private fun transformGitError(throwable: Throwable): Throwable { private fun transformGitError(throwable: Throwable): Throwable {
val err = rootCauseException(throwable) val err = rootCauseException(throwable)
return when { return when {
err.message?.contains("cannot open additional channels") == true -> { err.message?.contains("cannot open additional channels") == true -> {
GitSettings.useMultiplexing = false GitSettings.useMultiplexing = false
SSHException(DisconnectReason.TOO_MANY_CONNECTIONS, "The server does not support multiple Git operations per SSH session. Please try again, a slower fallback mode will be used.") SSHException(
DisconnectReason.TOO_MANY_CONNECTIONS,
"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 -> { 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") IllegalStateException(
"Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings"
)
} }
err is TransportException && err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> { err is TransportException && err.disconnectReason == DisconnectReason.HOST_KEY_NOT_VERIFIABLE -> {
SSHException(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." "WARNING: The remote host key has changed. If this is expected, please go to Git server settings and clear the saved host key."
) )
} }
@ -135,9 +135,7 @@ abstract class BaseGitActivity : ContinuationContainerActivity() {
private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean { private fun isExplicitlyUserInitiatedError(throwable: Throwable): Boolean {
var cause: Throwable? = throwable var cause: Throwable? = throwable
while (cause != null) { while (cause != null) {
if (cause is SSHException && if (cause is SSHException && cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) return true
cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER)
return true
cause = cause.cause cause = cause.cause
} }
return false return false
@ -149,14 +147,14 @@ abstract class BaseGitActivity : ContinuationContainerActivity() {
*/ */
private fun rootCauseException(throwable: Throwable): Throwable { private fun rootCauseException(throwable: Throwable): Throwable {
var rootCause = throwable var rootCause = throwable
// JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ exceptions. // JGit's InvalidRemoteException and TransportException hide the more helpful SSHJ
// exceptions.
// Also, SSHJ's UserAuthException about exhausting available authentication methods hides // Also, SSHJ's UserAuthException about exhausting available authentication methods hides
// more useful exceptions. // more useful exceptions.
while ((rootCause is org.eclipse.jgit.errors.TransportException || while ((rootCause is org.eclipse.jgit.errors.TransportException ||
rootCause is org.eclipse.jgit.api.errors.TransportException || rootCause is org.eclipse.jgit.api.errors.TransportException ||
rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException || rootCause is org.eclipse.jgit.api.errors.InvalidRemoteException ||
(rootCause is UserAuthException && (rootCause is UserAuthException && rootCause.message == "Exhausted available authentication methods"))) {
rootCause.message == "Exhausted available authentication methods"))) {
rootCause = rootCause.cause ?: break rootCause = rootCause.cause ?: break
} }
return rootCause return rootCause

View file

@ -40,10 +40,8 @@ class GitConfigActivity : BaseGitActivity() {
setContentView(binding.root) setContentView(binding.root)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (GitSettings.authorName.isEmpty()) if (GitSettings.authorName.isEmpty()) binding.gitUserName.requestFocus()
binding.gitUserName.requestFocus() else binding.gitUserName.setText(GitSettings.authorName)
else
binding.gitUserName.setText(GitSettings.authorName)
binding.gitUserEmail.setText(GitSettings.authorEmail) binding.gitUserEmail.setText(GitSettings.authorEmail)
setupTools() setupTools()
binding.saveButton.setOnClickListener { binding.saveButton.setOnClickListener {
@ -73,9 +71,7 @@ class GitConfigActivity : BaseGitActivity() {
} }
} }
/** /** Sets up the UI components of the tools section. */
* Sets up the UI components of the tools section.
*/
private fun setupTools() { private fun setupTools() {
val repo = PasswordRepository.getRepository(null) val repo = PasswordRepository.getRepository(null)
if (repo != null) { if (repo != null) {
@ -86,45 +82,39 @@ class GitConfigActivity : BaseGitActivity() {
binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
} }
binding.gitLog.setOnClickListener { binding.gitLog.setOnClickListener {
runCatching { runCatching { startActivity(Intent(this, GitLogActivity::class.java)) }.onFailure { ex ->
startActivity(Intent(this, GitLogActivity::class.java))
}.onFailure { ex ->
e(ex) { "Failed to start GitLogActivity" } e(ex) { "Failed to start GitLogActivity" }
} }
} }
binding.gitAbortRebase.setOnClickListener { binding.gitAbortRebase.setOnClickListener {
lifecycleScope.launch { lifecycleScope.launch {
launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED).fold( launchGitOperation(GitOp.BREAK_OUT_OF_DETACHED)
.fold(
success = { success = {
MaterialAlertDialogBuilder(this@GitConfigActivity).run { MaterialAlertDialogBuilder(this@GitConfigActivity).run {
setTitle(resources.getString(R.string.git_abort_and_push_title)) setTitle(resources.getString(R.string.git_abort_and_push_title))
setMessage(resources.getString( setMessage(
resources.getString(
R.string.git_break_out_of_detached_success, R.string.git_break_out_of_detached_success,
GitSettings.branch, GitSettings.branch,
"conflicting-${GitSettings.branch}-...", "conflicting-${GitSettings.branch}-...",
)) )
)
setOnDismissListener { finish() } setOnDismissListener { finish() }
setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> } setPositiveButton(resources.getString(R.string.dialog_ok)) { _, _ -> }
show() show()
} }
}, },
failure = { err -> failure = { err -> promptOnErrorHandler(err) { finish() } },
promptOnErrorHandler(err) {
finish()
}
},
) )
} }
} }
binding.gitResetToRemote.setOnClickListener { binding.gitResetToRemote.setOnClickListener {
lifecycleScope.launch { lifecycleScope.launch {
launchGitOperation(GitOp.RESET).fold( launchGitOperation(GitOp.RESET)
.fold(
success = ::finishOnSuccessHandler, success = ::finishOnSuccessHandler,
failure = { err -> failure = { err -> promptOnErrorHandler(err) { finish() } },
promptOnErrorHandler(err) {
finish()
}
},
) )
} }
} }
@ -146,7 +136,8 @@ class GitConfigActivity : BaseGitActivity() {
val commitHash = headRef.objectId.abbreviate(8).name() val commitHash = headRef.objectId.abbreviate(8).name()
getString(R.string.git_head_detached, commitHash) getString(R.string.git_head_detached, commitHash)
} }
}.getOrElse { ex -> }
.getOrElse { ex ->
e(ex) { "Error getting HEAD reference" } e(ex) { "Error getting HEAD reference" }
getString(R.string.git_head_missing) getString(R.string.git_head_missing)
} }

View file

@ -73,10 +73,12 @@ class GitServerConfigActivity : BaseGitActivity() {
} }
} }
binding.serverUrl.setText(GitSettings.url.also { binding.serverUrl.setText(
GitSettings.url.also {
if (it.isNullOrEmpty()) return@also if (it.isNullOrEmpty()) return@also
setAuthModes(it.startsWith("http://") || it.startsWith("https://")) setAuthModes(it.startsWith("http://") || it.startsWith("https://"))
}) }
)
binding.serverBranch.setText(GitSettings.branch) binding.serverBranch.setText(GitSettings.branch)
binding.serverUrl.doOnTextChanged { text, _, _, _ -> binding.serverUrl.doOnTextChanged { text, _, _, _ ->
@ -100,9 +102,7 @@ class GitServerConfigActivity : BaseGitActivity() {
BasicBottomSheet.Builder(this) BasicBottomSheet.Builder(this)
.setTitleRes(R.string.https_scheme_with_port_title) .setTitleRes(R.string.https_scheme_with_port_title)
.setMessageRes(R.string.https_scheme_with_port_message) .setMessageRes(R.string.https_scheme_with_port_message)
.setPositiveButtonClickListener { .setPositiveButtonClickListener { binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) }
binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/"))
}
.build() .build()
.show(supportFragmentManager, "SSH_SCHEME_WARNING") .show(supportFragmentManager, "SSH_SCHEME_WARNING")
return@setOnClickListener return@setOnClickListener
@ -110,55 +110,53 @@ class GitServerConfigActivity : BaseGitActivity() {
BasicBottomSheet.Builder(this) BasicBottomSheet.Builder(this)
.setTitleRes(R.string.ssh_scheme_needed_title) .setTitleRes(R.string.ssh_scheme_needed_title)
.setMessageRes(R.string.ssh_scheme_needed_message) .setMessageRes(R.string.ssh_scheme_needed_message)
.setPositiveButtonClickListener { .setPositiveButtonClickListener { @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl") }
@Suppress("SetTextI18n")
binding.serverUrl.setText("ssh://$newUrl")
}
.build() .build()
.show(supportFragmentManager, "SSH_SCHEME_WARNING") .show(supportFragmentManager, "SSH_SCHEME_WARNING")
return@setOnClickListener return@setOnClickListener
} }
} }
when (val updateResult = GitSettings.updateConnectionSettingsIfValid( when (val updateResult =
GitSettings.updateConnectionSettingsIfValid(
newAuthMode = newAuthMode, newAuthMode = newAuthMode,
newUrl = binding.serverUrl.text.toString().trim(), newUrl = binding.serverUrl.text.toString().trim(),
newBranch = binding.serverBranch.text.toString().trim())) { newBranch = binding.serverBranch.text.toString().trim()
)
) {
GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> { GitSettings.UpdateConnectionSettingsResult.FailedToParseUrl -> {
Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show() Snackbar.make(binding.root, getString(R.string.git_server_config_save_error), Snackbar.LENGTH_LONG).show()
} }
is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> { is GitSettings.UpdateConnectionSettingsResult.MissingUsername -> {
when (updateResult.newProtocol) { when (updateResult.newProtocol) {
Protocol.Https -> Protocol.Https ->
BasicBottomSheet.Builder(this) BasicBottomSheet.Builder(this)
.setTitleRes(R.string.ssh_scheme_needed_title) .setTitleRes(R.string.ssh_scheme_needed_title)
.setMessageRes(R.string.git_server_config_save_missing_username_https) .setMessageRes(R.string.git_server_config_save_missing_username_https)
.setPositiveButtonClickListener { .setPositiveButtonClickListener {}
}
.build() .build()
.show(supportFragmentManager, "HTTPS_MISSING_USERNAME") .show(supportFragmentManager, "HTTPS_MISSING_USERNAME")
Protocol.Ssh -> Protocol.Ssh ->
BasicBottomSheet.Builder(this) BasicBottomSheet.Builder(this)
.setTitleRes(R.string.ssh_scheme_needed_title) .setTitleRes(R.string.ssh_scheme_needed_title)
.setMessageRes(R.string.git_server_config_save_missing_username_ssh) .setMessageRes(R.string.git_server_config_save_missing_username_ssh)
.setPositiveButtonClickListener { .setPositiveButtonClickListener {}
}
.build() .build()
.show(supportFragmentManager, "SSH_MISSING_USERNAME") .show(supportFragmentManager, "SSH_MISSING_USERNAME")
} }
} }
GitSettings.UpdateConnectionSettingsResult.Valid -> { GitSettings.UpdateConnectionSettingsResult.Valid -> {
if (isClone && PasswordRepository.getRepository(null) == null) if (isClone && PasswordRepository.getRepository(null) == null) PasswordRepository.initialize()
PasswordRepository.initialize()
if (!isClone) { if (!isClone) {
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT).show() Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT)
.show()
Handler(Looper.getMainLooper()).postDelayed(500) { finish() } Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
} else { } else {
cloneRepository() cloneRepository()
} }
} }
is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> { is GitSettings.UpdateConnectionSettingsResult.AuthModeMismatch -> {
val message = getString( val message =
getString(
R.string.git_server_config_save_auth_mode_mismatch, R.string.git_server_config_save_auth_mode_mismatch,
updateResult.newProtocol, updateResult.newProtocol,
updateResult.validModes.joinToString(", "), updateResult.validModes.joinToString(", "),
@ -179,31 +177,28 @@ class GitServerConfigActivity : BaseGitActivity() {
} }
} }
private fun setAuthModes(isHttps: Boolean) = with(binding) { private fun setAuthModes(isHttps: Boolean) =
with(binding) {
if (isHttps) { if (isHttps) {
authModeSshKey.isVisible = false authModeSshKey.isVisible = false
authModeOpenKeychain.isVisible = false authModeOpenKeychain.isVisible = false
authModePassword.isVisible = true authModePassword.isVisible = true
if (authModeGroup.checkedChipId != authModePassword.id) if (authModeGroup.checkedChipId != authModePassword.id) authModeGroup.check(View.NO_ID)
authModeGroup.check(View.NO_ID)
} else { } else {
authModeSshKey.isVisible = true authModeSshKey.isVisible = true
authModeOpenKeychain.isVisible = true authModeOpenKeychain.isVisible = true
authModePassword.isVisible = true authModePassword.isVisible = true
if (authModeGroup.checkedChipId == View.NO_ID) if (authModeGroup.checkedChipId == View.NO_ID) authModeGroup.check(authModeSshKey.id)
authModeGroup.check(authModeSshKey.id)
} }
} }
/** /** Clones the repository, the directory exists, deletes it */
* Clones the repository, the directory exists, deletes it
*/
private fun cloneRepository() { private fun cloneRepository() {
val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory()) val localDir = requireNotNull(PasswordRepository.getRepositoryDirectory())
val localDirFiles = localDir.listFiles() ?: emptyArray() val localDirFiles = localDir.listFiles() ?: emptyArray()
// Warn if non-empty folder unless it's a just-initialized store that has just a .git folder // Warn if non-empty folder unless it's a just-initialized store that has just a .git folder
if (localDir.exists() && localDirFiles.isNotEmpty() && if (localDir.exists() && localDirFiles.isNotEmpty() && !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")
!(localDirFiles.size == 1 && localDirFiles[0].name == ".git")) { ) {
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle(R.string.dialog_delete_title) .setTitle(R.string.dialog_delete_title)
.setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString())) .setMessage(resources.getString(R.string.dialog_delete_msg, localDir.toString()))
@ -211,32 +206,30 @@ class GitServerConfigActivity : BaseGitActivity() {
.setPositiveButton(R.string.dialog_delete) { dialog, _ -> .setPositiveButton(R.string.dialog_delete) { dialog, _ ->
runCatching { runCatching {
lifecycleScope.launch { lifecycleScope.launch {
val snackbar = snackbar(message = getString(R.string.delete_directory_progress_text), length = Snackbar.LENGTH_INDEFINITE) val snackbar =
withContext(Dispatchers.IO) { snackbar(
localDir.deleteRecursively() message = getString(R.string.delete_directory_progress_text),
} length = Snackbar.LENGTH_INDEFINITE
)
withContext(Dispatchers.IO) { localDir.deleteRecursively() }
snackbar.dismiss() snackbar.dismiss()
launchGitOperation(GitOp.CLONE).fold( launchGitOperation(GitOp.CLONE)
.fold(
success = { success = {
setResult(RESULT_OK) setResult(RESULT_OK)
finish() finish()
}, },
failure = { err -> failure = { err -> promptOnErrorHandler(err) { finish() } }
promptOnErrorHandler(err) {
finish()
}
}
) )
} }
}.onFailure { e -> }
.onFailure { e ->
e.printStackTrace() e.printStackTrace()
MaterialAlertDialogBuilder(this).setMessage(e.message).show() MaterialAlertDialogBuilder(this).setMessage(e.message).show()
} }
dialog.cancel() dialog.cancel()
} }
.setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> .setNegativeButton(R.string.dialog_do_not_delete) { dialog, _ -> dialog.cancel() }
dialog.cancel()
}
.show() .show()
} else { } else {
runCatching { runCatching {
@ -244,12 +237,14 @@ class GitServerConfigActivity : BaseGitActivity() {
if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") { if (localDir.exists() && localDirFiles.size == 1 && localDirFiles[0].name == ".git") {
localDir.deleteRecursively() localDir.deleteRecursively()
} }
}.onFailure { e -> }
.onFailure { e ->
e(e) e(e)
MaterialAlertDialogBuilder(this).setMessage(e.message).show() MaterialAlertDialogBuilder(this).setMessage(e.message).show()
} }
lifecycleScope.launch { lifecycleScope.launch {
launchGitOperation(GitOp.CLONE).fold( launchGitOperation(GitOp.CLONE)
.fold(
success = { success = {
setResult(RESULT_OK) setResult(RESULT_OK)
finish() finish()
@ -265,9 +260,7 @@ class GitServerConfigActivity : BaseGitActivity() {
private val PORT_REGEX = ":[0-9]{1,5}/".toRegex() private val PORT_REGEX = ":[0-9]{1,5}/".toRegex()
fun createCloneIntent(context: Context): Intent { fun createCloneIntent(context: Context): Intent {
return Intent(context, GitServerConfigActivity::class.java).apply { return Intent(context, GitServerConfigActivity::class.java).apply { putExtra("cloning", true) }
putExtra("cloning", true)
}
} }
} }
} }

View file

@ -23,9 +23,7 @@ private fun stringFrom(date: Date): String {
return DateFormat.getDateTimeInstance().format(date) return DateFormat.getDateTimeInstance().format(date)
} }
/** /** @see GitLogActivity */
* @see GitLogActivity
*/
class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() { class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
private val model = GitLogModel() private val model = GitLogModel()
@ -49,7 +47,8 @@ class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) { class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(commit: GitCommit) = with(binding) { fun bind(commit: GitCommit) =
with(binding) {
gitLogRowMessage.text = commit.shortMessage gitLogRowMessage.text = commit.shortMessage
gitLogRowHash.text = shortHash(commit.hash) gitLogRowHash.text = shortHash(commit.hash)
gitLogRowTime.text = stringFrom(commit.time) gitLogRowTime.text = stringFrom(commit.time)

View file

@ -42,15 +42,15 @@ class LaunchActivity : AppCompatActivity() {
} }
private fun startTargetActivity(noAuth: Boolean) { private fun startTargetActivity(noAuth: Boolean) {
val intentToStart = if (intent.action == ACTION_DECRYPT_PASS) val intentToStart =
if (intent.action == ACTION_DECRYPT_PASS)
Intent(this, DecryptActivity::class.java).apply { Intent(this, DecryptActivity::class.java).apply {
putExtra("NAME", intent.getStringExtra("NAME")) putExtra("NAME", intent.getStringExtra("NAME"))
putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH")) putExtra("FILE_PATH", intent.getStringExtra("FILE_PATH"))
putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH")) putExtra("REPO_PATH", intent.getStringExtra("REPO_PATH"))
putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L)) putExtra("LAST_CHANGED_TIMESTAMP", intent.getLongExtra("LAST_CHANGED_TIMESTAMP", 0L))
} }
else else Intent(this, PasswordStore::class.java)
Intent(this, PasswordStore::class.java)
startActivity(intentToStart) startActivity(intentToStart)
Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L) Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L)

View file

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

View file

@ -33,7 +33,8 @@ class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
private val binding by viewBinding(FragmentKeySelectionBinding::bind) private val binding by viewBinding(FragmentKeySelectionBinding::bind)
private val gpgKeySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val gpgKeySelectAction =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) { if (result.resultCode == AppCompatActivity.RESULT_OK) {
result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds -> result.data?.getStringArrayExtra(OpenPgpApi.EXTRA_KEY_IDS)?.let { keyIds ->
lifecycleScope.launch { lifecycleScope.launch {
@ -42,10 +43,7 @@ class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) gpgIdentifierFile.writeText((keyIds + "").joinToString("\n"))
} }
settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) } settings.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, true) }
requireActivity().commitChange(getString( requireActivity().commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name)))
R.string.git_commit_gpg_id,
getString(R.string.app_name)
))
} }
} }
} else { } else {
@ -56,7 +54,9 @@ class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.selectKey.setOnClickListener { gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java)) } binding.selectKey.setOnClickListener {
gpgKeySelectAction.launch(Intent(requireContext(), GetKeyIdsActivity::class.java))
}
} }
companion object { companion object {

View file

@ -36,18 +36,22 @@ import java.io.File
class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) { class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs } private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
private val directorySelectIntent by lazy(LazyThreadSafetyMode.NONE) { Intent(requireContext(), DirectorySelectionActivity::class.java) } private val directorySelectIntent by lazy(LazyThreadSafetyMode.NONE) {
Intent(requireContext(), DirectorySelectionActivity::class.java)
}
private val binding by viewBinding(FragmentRepoLocationBinding::bind) private val binding by viewBinding(FragmentRepoLocationBinding::bind)
private val sortOrder: PasswordSortOrder private val sortOrder: PasswordSortOrder
get() = PasswordSortOrder.getSortOrder(settings) get() = PasswordSortOrder.getSortOrder(settings)
private val repositoryInitAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val repositoryInitAction =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) { if (result.resultCode == AppCompatActivity.RESULT_OK) {
initializeRepositoryInfo() initializeRepositoryInfo()
} }
} }
private val externalDirectorySelectAction = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val externalDirectorySelectAction =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) { if (result.resultCode == AppCompatActivity.RESULT_OK) {
if (checkExternalDirectory()) { if (checkExternalDirectory()) {
finish() finish()
@ -61,9 +65,7 @@ class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
externalDirectorySelectAction.launch(directorySelectIntent) externalDirectorySelectAction.launch(directorySelectIntent)
} }
private val repositoryUsePermGrantedAction = createPermGrantedAction { private val repositoryUsePermGrantedAction = createPermGrantedAction { initializeRepositoryInfo() }
initializeRepositoryInfo()
}
private val repositoryChangePermGrantedAction = createPermGrantedAction { private val repositoryChangePermGrantedAction = createPermGrantedAction {
repositoryInitAction.launch(directorySelectIntent) repositoryInitAction.launch(directorySelectIntent)
@ -71,18 +73,12 @@ class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.hidden.setOnClickListener { binding.hidden.setOnClickListener { createRepoInHiddenDir() }
createRepoInHiddenDir()
binding.sdcard.setOnClickListener { createRepoFromExternalDir() }
} }
binding.sdcard.setOnClickListener { /** Initializes an empty repository in the app's private directory */
createRepoFromExternalDir()
}
}
/**
* Initializes an empty repository in the app's private directory
*/
private fun createRepoInHiddenDir() { private fun createRepoInHiddenDir() {
settings.edit { settings.edit {
putBoolean(PreferenceKeys.GIT_EXTERNAL, false) putBoolean(PreferenceKeys.GIT_EXTERNAL, false)
@ -91,9 +87,7 @@ class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
initializeRepositoryInfo() initializeRepositoryInfo()
} }
/** /** Initializes an empty repository in a selected directory if one does not already exist */
* Initializes an empty repository in a selected directory if one does not already exist
*/
private fun createRepoFromExternalDir() { private fun createRepoFromExternalDir() {
settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) } settings.edit { putBoolean(PreferenceKeys.GIT_EXTERNAL, true) }
val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) val externalRepo = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
@ -129,7 +123,8 @@ class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
private fun checkExternalDirectory(): Boolean { private fun checkExternalDirectory(): Boolean {
if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) && if (settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) &&
settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null) { settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) != null
) {
val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO) val externalRepoPath = settings.getString(PreferenceKeys.GIT_EXTERNAL_REPO)
val dir = externalRepoPath?.let { File(it) } val dir = externalRepoPath?.let { File(it) }
if (dir != null && // The directory could be opened if (dir != null && // The directory could be opened
@ -155,7 +150,8 @@ class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
PasswordRepository.initialize() PasswordRepository.initialize()
} }
parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance()) parentFragmentManager.performTransactionWithBackStack(KeySelectionFragment.newInstance())
}.onFailure { e -> }
.onFailure { e ->
e(e) e(e)
if (!localDir.delete()) { if (!localDir.delete()) {
d { "Failed to delete local repository: $localDir" } d { "Failed to delete local repository: $localDir" }

View file

@ -24,7 +24,9 @@ class WelcomeFragment : Fragment(R.layout.fragment_welcome) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding.letsGo.setOnClickListener { parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance()) } binding.letsGo.setOnClickListener {
parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance())
}
binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), SettingsActivity::class.java)) } binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), SettingsActivity::class.java)) }
} }
} }

View file

@ -59,7 +59,8 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
private val model: SearchableRepositoryViewModel by activityViewModels() private val model: SearchableRepositoryViewModel by activityViewModels()
private val binding by viewBinding(PasswordRecyclerViewBinding::bind) private val binding by viewBinding(PasswordRecyclerViewBinding::bind)
private val swipeResult = registerForActivityResult(StartActivityForResult()) { private val swipeResult =
registerForActivityResult(StartActivityForResult()) {
binding.swipeRefresher.isRefreshing = false binding.swipeRefresher.isRefreshing = false
requireStore().refreshPasswordList() requireStore().refreshPasswordList()
} }
@ -71,9 +72,7 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
settings = requireContext().sharedPrefs settings = requireContext().sharedPrefs
initializePasswordList() initializePasswordList()
binding.fab.setOnClickListener { binding.fab.setOnClickListener { ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") }
ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET")
}
childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle -> childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
when (bundle.getString(ACTION_KEY)) { when (bundle.getString(ACTION_KEY)) {
ACTION_FOLDER -> requireStore().createFolder() ACTION_FOLDER -> requireStore().createFolder()
@ -101,40 +100,37 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
} else { } else {
// When authentication is set to AuthMode.None then the only git operation we can // When authentication is set to AuthMode.None then the only git operation we can
// run is a pull, so automatically fallback to that. // run is a pull, so automatically fallback to that.
val operationId = when (GitSettings.authMode) { val operationId =
when (GitSettings.authMode) {
AuthMode.None -> BaseGitActivity.GitOp.PULL AuthMode.None -> BaseGitActivity.GitOp.PULL
else -> BaseGitActivity.GitOp.SYNC else -> BaseGitActivity.GitOp.SYNC
} }
requireStore().apply { requireStore().apply {
lifecycleScope.launch { lifecycleScope.launch {
launchGitOperation(operationId).fold( launchGitOperation(operationId)
.fold(
success = { success = {
binding.swipeRefresher.isRefreshing = false binding.swipeRefresher.isRefreshing = false
refreshPasswordList() refreshPasswordList()
}, },
failure = { err -> failure = { err -> promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false } },
promptOnErrorHandler(err) {
binding.swipeRefresher.isRefreshing = false
}
},
) )
} }
} }
} }
} }
recyclerAdapter = PasswordItemRecyclerAdapter() recyclerAdapter =
.onItemClicked { _, item -> PasswordItemRecyclerAdapter()
listener.onFragmentInteraction(item) .onItemClicked { _, item -> listener.onFragmentInteraction(item) }
}
.onSelectionChanged { selection -> .onSelectionChanged { selection ->
// In order to not interfere with drag selection, we disable the SwipeRefreshLayout // In order to not interfere with drag selection, we disable the
// SwipeRefreshLayout
// once an item is selected. // once an item is selected.
binding.swipeRefresher.isEnabled = selection.isEmpty binding.swipeRefresher.isEnabled = selection.isEmpty
if (actionMode == null) if (actionMode == null)
actionMode = requireStore().startSupportActionMode(actionModeCallback) actionMode = requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged
?: return@onSelectionChanged
if (!selection.isEmpty) { if (!selection.isEmpty) {
actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size()) actionMode!!.title = resources.getQuantityString(R.plurals.delete_title, selection.size(), selection.size())
@ -164,22 +160,20 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
recyclerAdapter.submitList(result.passwordItems) { recyclerAdapter.submitList(result.passwordItems) {
when { when {
result.isFiltered -> { result.isFiltered -> {
// When the result is filtered, we always scroll to the top since that is where // When the result is filtered, we always scroll to the top since that is
// where
// the best fuzzy match appears. // the best fuzzy match appears.
recyclerView.scrollToPosition(0) recyclerView.scrollToPosition(0)
} }
scrollTarget != null -> { scrollTarget != null -> {
scrollTarget?.let { scrollTarget?.let { recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) }
recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it))
}
scrollTarget = null scrollTarget = null
} }
else -> { else -> {
// When the result is not filtered and there is a saved scroll position for it, // When the result is not filtered and there is a saved scroll position for
// it,
// we try to restore it. // we try to restore it.
recyclerViewStateToRestore?.let { recyclerViewStateToRestore?.let { recyclerView.layoutManager!!.onRestoreInstanceState(it) }
recyclerView.layoutManager!!.onRestoreInstanceState(it)
}
recyclerViewStateToRestore = null recyclerViewStateToRestore = null
} }
} }
@ -187,7 +181,8 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
} }
} }
private val actionModeCallback = object : ActionMode.Callback { private val actionModeCallback =
object : ActionMode.Callback {
// Called when the action mode is created; startActionMode() was called // Called when the action mode is created; startActionMode() was called
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
// Inflate a menu resource providing context menu items // Inflate a menu resource providing context menu items
@ -197,12 +192,12 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
return true return true
} }
// Called each time the action mode is shown. Always called after onCreateActionMode, but // Called each time the action mode is shown. Always called after onCreateActionMode,
// but
// may be called multiple times if the mode is invalidated. // may be called multiple times if the mode is invalidated.
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.menu_edit_password).isVisible = menu.findItem(R.id.menu_edit_password).isVisible =
recyclerAdapter.getSelectedItems() recyclerAdapter.getSelectedItems().all { it.type == PasswordItem.TYPE_CATEGORY }
.all { it.type == PasswordItem.TYPE_CATEGORY }
return true return true
} }
@ -236,13 +231,12 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
animateFab(true) animateFab(true)
} }
private fun animateFab(show: Boolean) = with(binding.fab) { private fun animateFab(show: Boolean) =
val animation = AnimationUtils.loadAnimation( with(binding.fab) {
context, if (show) R.anim.scale_up else R.anim.scale_down val animation = AnimationUtils.loadAnimation(context, if (show) R.anim.scale_up else R.anim.scale_down)
) animation.setAnimationListener(
animation.setAnimationListener(object : Animation.AnimationListener { object : Animation.AnimationListener {
override fun onAnimationRepeat(animation: Animation?) { override fun onAnimationRepeat(animation: Animation?) {}
}
override fun onAnimationEnd(animation: Animation?) { override fun onAnimationEnd(animation: Animation?) {
if (!show) visibility = View.GONE if (!show) visibility = View.GONE
@ -251,11 +245,9 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
override fun onAnimationStart(animation: Animation?) { override fun onAnimationStart(animation: Animation?) {
if (show) visibility = View.VISIBLE if (show) visibility = View.VISIBLE
} }
}) }
animate().rotationBy(if (show) -90f else 90f) )
.setStartDelay(if (show) 100 else 0) animate().rotationBy(if (show) -90f else 90f).setStartDelay(if (show) 100 else 0).setDuration(100).start()
.setDuration(100)
.start()
startAnimation(animation) startAnimation(animation)
} }
} }
@ -263,14 +255,13 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
override fun onAttach(context: Context) { override fun onAttach(context: Context) {
super.onAttach(context) super.onAttach(context)
runCatching { runCatching {
listener = object : OnFragmentInteractionListener { listener =
object : OnFragmentInteractionListener {
override fun onFragmentInteraction(item: PasswordItem) { override fun onFragmentInteraction(item: PasswordItem) {
if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) { if (settings.getString(PreferenceKeys.SORT_ORDER) == PasswordSortOrder.RECENTLY_USED.name) {
//save the time when password was used // save the time when password was used
val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) val preferences = context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
preferences.edit { preferences.edit { putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) }
putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString())
}
} }
if (item.type == PasswordItem.TYPE_CATEGORY) { if (item.type == PasswordItem.TYPE_CATEGORY) {
@ -284,24 +275,19 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
} }
} }
} }
}.onFailure {
throw ClassCastException("$context must implement OnFragmentInteractionListener")
} }
.onFailure { throw ClassCastException("$context must implement OnFragmentInteractionListener") }
} }
private fun requireStore() = requireActivity() as PasswordStore private fun requireStore() = requireActivity() as PasswordStore
/** /** Returns true if the back press was handled by the [Fragment]. */
* Returns true if the back press was handled by the [Fragment].
*/
fun onBackPressedInActivity(): Boolean { fun onBackPressedInActivity(): Boolean {
if (!model.canNavigateBack) if (!model.canNavigateBack) return false
return false
// The RecyclerView state is restored when the asynchronous update operation on the // The RecyclerView state is restored when the asynchronous update operation on the
// adapter is completed. // adapter is completed.
recyclerViewStateToRestore = model.navigateBack() recyclerViewStateToRestore = model.navigateBack()
if (!model.canNavigateBack) if (!model.canNavigateBack) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(false)
return true return true
} }
@ -323,13 +309,9 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
} }
} }
fun navigateTo(file: File) { fun navigateTo(file: File) {
requireStore().clearSearch() requireStore().clearSearch()
model.navigateTo( model.navigateTo(file, recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState())
file,
recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState()
)
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true) requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
} }

View file

@ -85,23 +85,25 @@ class PasswordStore : BaseGitActivity() {
ViewModelProvider.AndroidViewModelFactory(application) ViewModelProvider.AndroidViewModelFactory(application)
} }
private val storagePermissionRequest = registerForActivityResult(RequestPermission()) { granted -> private val storagePermissionRequest =
if (granted) checkLocalRepository() registerForActivityResult(RequestPermission()) { granted -> if (granted) checkLocalRepository() }
}
private val directorySelectAction = registerForActivityResult(StartActivityForResult()) { result -> private val directorySelectAction =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) { if (result.resultCode == RESULT_OK) {
checkLocalRepository() checkLocalRepository()
} }
} }
private val listRefreshAction = registerForActivityResult(StartActivityForResult()) { result -> private val listRefreshAction =
registerForActivityResult(StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) { if (result.resultCode == RESULT_OK) {
refreshPasswordList() refreshPasswordList()
} }
} }
private val passwordMoveAction = registerForActivityResult(StartActivityForResult()) { result -> private val passwordMoveAction =
registerForActivityResult(StartActivityForResult()) { result ->
val intentData = result.data ?: return@registerForActivityResult val intentData = result.data ?: return@registerForActivityResult
val filesToMove = requireNotNull(intentData.getStringArrayExtra("Files")) val filesToMove = requireNotNull(intentData.getStringArrayExtra("Files"))
val target = File(requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH"))) val target = File(requireNotNull(intentData.getStringExtra("SELECTED_FOLDER_PATH")))
@ -130,23 +132,15 @@ class PasswordStore : BaseGitActivity() {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
MaterialAlertDialogBuilder(this@PasswordStore) MaterialAlertDialogBuilder(this@PasswordStore)
.setTitle(resources.getString(R.string.password_exists_title)) .setTitle(resources.getString(R.string.password_exists_title))
.setMessage(resources.getString( .setMessage(resources.getString(R.string.password_exists_message, destinationLongName, sourceLongName))
R.string.password_exists_message,
destinationLongName,
sourceLongName)
)
.setPositiveButton(R.string.dialog_ok) { _, _ -> .setPositiveButton(R.string.dialog_ok) { _, _ ->
launch(Dispatchers.IO) { launch(Dispatchers.IO) { moveFile(source, destinationFile) }
moveFile(source, destinationFile)
}
} }
.setNegativeButton(R.string.dialog_cancel, null) .setNegativeButton(R.string.dialog_cancel, null)
.show() .show()
} }
} else { } else {
launch(Dispatchers.IO) { launch(Dispatchers.IO) { moveFile(source, destinationFile) }
moveFile(source, destinationFile)
}
} }
} }
when (filesToMove.size) { when (filesToMove.size) {
@ -179,7 +173,8 @@ class PasswordStore : BaseGitActivity() {
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
// open search view on search key, or Ctr+F // open search view on search key, or Ctr+F
if ((keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && if ((keyCode == KeyEvent.KEYCODE_SEARCH || keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) &&
!searchItem.isActionViewExpanded) { !searchItem.isActionViewExpanded
) {
searchItem.expandActionView() searchItem.expandActionView()
return true return true
} }
@ -200,8 +195,10 @@ class PasswordStore : BaseGitActivity() {
// If user opens app with permission granted then revokes and returns, // If user opens app with permission granted then revokes and returns,
// prevent attempt to create password list fragment // prevent attempt to create password list fragment
var savedInstance = savedInstanceState var savedInstance = savedInstanceState
if (savedInstanceState != null && (!settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) || if (savedInstanceState != null &&
!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE))) { (!settings.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) ||
!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE))
) {
savedInstance = null savedInstance = null
} }
super.onCreate(savedInstance) super.onCreate(savedInstance)
@ -209,12 +206,7 @@ class PasswordStore : BaseGitActivity() {
model.currentDir.observe(this) { dir -> model.currentDir.observe(this) { dir ->
val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile val basePath = PasswordRepository.getRepositoryDirectory().absoluteFile
supportActionBar!!.apply { supportActionBar!!.apply { if (dir != basePath) title = dir.name else setTitle(R.string.app_name) }
if (dir != basePath)
title = dir.name
else
setTitle(R.string.app_name)
}
} }
} }
@ -238,7 +230,8 @@ class PasswordStore : BaseGitActivity() {
} }
override fun onCreateOptionsMenu(menu: Menu?): Boolean { override fun onCreateOptionsMenu(menu: Menu?): Boolean {
val menuRes = when { val menuRes =
when {
GitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth GitSettings.authMode == AuthMode.None -> R.menu.main_menu_no_auth
PasswordRepository.isGitRepo() -> R.menu.main_menu_git PasswordRepository.isGitRepo() -> R.menu.main_menu_git
else -> R.menu.main_menu_non_git else -> R.menu.main_menu_non_git
@ -264,16 +257,12 @@ class PasswordStore : BaseGitActivity() {
val filter = s.trim() val filter = s.trim()
// List the contents of the current directory if the user enters a blank // List the contents of the current directory if the user enters a blank
// search term. // search term.
if (filter.isEmpty()) if (filter.isEmpty()) model.navigateTo(newDirectory = model.currentDir.value!!, pushPreviousLocation = false)
model.navigateTo( else model.search(filter)
newDirectory = model.currentDir.value!!,
pushPreviousLocation = false
)
else
model.search(filter)
return true return true
} }
}) }
)
// When using the support library, the setOnActionExpandListener() method is // When using the support library, the setOnActionExpandListener() method is
// static and accepts the MenuItem object as an argument // static and accepts the MenuItem object as an argument
@ -287,7 +276,8 @@ class PasswordStore : BaseGitActivity() {
override fun onMenuItemActionExpand(item: MenuItem): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
return true return true
} }
}) }
)
if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) { if (settings.getBoolean(PreferenceKeys.SEARCH_ON_START, false)) {
searchItem.expandActionView() searchItem.expandActionView()
} }
@ -296,16 +286,13 @@ class PasswordStore : BaseGitActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
val id = item.itemId val id = item.itemId
val initBefore = MaterialAlertDialogBuilder(this) val initBefore =
MaterialAlertDialogBuilder(this)
.setMessage(resources.getString(R.string.creation_dialog_text)) .setMessage(resources.getString(R.string.creation_dialog_text))
.setPositiveButton(resources.getString(R.string.dialog_ok), null) .setPositiveButton(resources.getString(R.string.dialog_ok), null)
when (id) { when (id) {
R.id.user_pref -> { R.id.user_pref -> {
runCatching { runCatching { startActivity(Intent(this, SettingsActivity::class.java)) }.onFailure { e -> e.printStackTrace() }
startActivity(Intent(this, SettingsActivity::class.java))
}.onFailure { e ->
e.printStackTrace()
}
} }
R.id.git_push -> { R.id.git_push -> {
if (!PasswordRepository.isInitialized) { if (!PasswordRepository.isInitialized) {
@ -336,8 +323,7 @@ class PasswordStore : BaseGitActivity() {
} }
override fun onBackPressed() { override fun onBackPressed() {
if (getPasswordFragment()?.onBackPressedInActivity() != true) if (getPasswordFragment()?.onBackPressedInActivity() != true) super.onBackPressed()
super.onBackPressed()
} }
private fun getPasswordFragment(): PasswordFragment? { private fun getPasswordFragment(): PasswordFragment? {
@ -345,20 +331,21 @@ class PasswordStore : BaseGitActivity() {
} }
fun clearSearch() { fun clearSearch() {
if (searchItem.isActionViewExpanded) if (searchItem.isActionViewExpanded) searchItem.collapseActionView()
searchItem.collapseActionView()
} }
private fun runGitOperation(operation: GitOp) = lifecycleScope.launch { private fun runGitOperation(operation: GitOp) =
launchGitOperation(operation).fold( lifecycleScope.launch {
launchGitOperation(operation)
.fold(
success = { refreshPasswordList() }, success = { refreshPasswordList() },
failure = { promptOnErrorHandler(it) }, failure = { promptOnErrorHandler(it) },
) )
} }
/** /**
* Validates if storage permission is granted, and requests for it if not. The return value * Validates if storage permission is granted, and requests for it if not. The return value is
* is true if the permission has been granted. * true if the permission has been granted.
*/ */
private fun hasRequiredStoragePermissions(): Boolean { private fun hasRequiredStoragePermissions(): Boolean {
return if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { return if (!isPermissionGranted(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
@ -389,8 +376,7 @@ class PasswordStore : BaseGitActivity() {
if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) { if (localDir != null && settings.getBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)) {
d { "Check, dir: ${localDir.absolutePath}" } d { "Check, dir: ${localDir.absolutePath}" }
// do not push the fragment if we already have it // do not push the fragment if we already have it
if (getPasswordFragment() == null || if (getPasswordFragment() == null || settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)) {
settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)) {
settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) } settings.edit { putBoolean(PreferenceKeys.REPO_CHANGED, false) }
val args = Bundle() val args = Bundle()
args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath) args.putString(REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
@ -434,7 +420,8 @@ class PasswordStore : BaseGitActivity() {
return -1 return -1
} }
iterator.next().commitTime.toLong() * 1000 iterator.next().commitTime.toLong() * 1000
}.getOr(-1) }
.getOr(-1)
} }
fun decryptPassword(item: PasswordItem) { fun decryptPassword(item: PasswordItem) {
@ -460,7 +447,8 @@ class PasswordStore : BaseGitActivity() {
@RequiresApi(Build.VERSION_CODES.N_MR1) @RequiresApi(Build.VERSION_CODES.N_MR1)
private fun addShortcut(item: PasswordItem, intent: Intent) { private fun addShortcut(item: PasswordItem, intent: Intent) {
val shortcutManager: ShortcutManager = getSystemService() ?: return val shortcutManager: ShortcutManager = getSystemService() ?: return
val shortcut = Builder(this, item.fullPathToParent) val shortcut =
Builder(this, item.fullPathToParent)
.setShortLabel(item.toString()) .setShortLabel(item.toString())
.setLongLabel(item.fullPathToParent + item.toString()) .setLongLabel(item.fullPathToParent + item.toString())
.setIcon(Icon.createWithResource(this, R.drawable.ic_lock_open_24px)) .setIcon(Icon.createWithResource(this, R.drawable.ic_lock_open_24px))
@ -509,12 +497,7 @@ class PasswordStore : BaseGitActivity() {
fun deletePasswords(selectedItems: List<PasswordItem>) { fun deletePasswords(selectedItems: List<PasswordItem>) {
var size = 0 var size = 0
selectedItems.forEach { selectedItems.forEach { if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size }
if (it.file.isFile)
size++
else
size += it.file.listFilesRecursively().size
}
if (size == 0) { if (size == 0) {
selectedItems.map { item -> item.file.deleteRecursively() } selectedItems.map { item -> item.file.deleteRecursively() }
refreshPasswordList() refreshPasswordList()
@ -525,15 +508,14 @@ class PasswordStore : BaseGitActivity() {
.setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ -> .setPositiveButton(resources.getString(R.string.dialog_yes)) { _, _ ->
val filesToDelete = arrayListOf<File>() val filesToDelete = arrayListOf<File>()
selectedItems.forEach { item -> selectedItems.forEach { item ->
if (item.file.isDirectory) if (item.file.isDirectory) filesToDelete.addAll(item.file.listFilesRecursively())
filesToDelete.addAll(item.file.listFilesRecursively()) else filesToDelete.add(item.file)
else
filesToDelete.add(item.file)
} }
selectedItems.map { item -> item.file.deleteRecursively() } selectedItems.map { item -> item.file.deleteRecursively() }
refreshPasswordList() refreshPasswordList()
AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete) AutofillMatcher.updateMatches(applicationContext, delete = filesToDelete)
val fmt = selectedItems.joinToString(separator = ", ") { item -> val fmt =
selectedItems.joinToString(separator = ", ") { item ->
item.file.toRelativeString(PasswordRepository.getRepositoryDirectory()) item.file.toRelativeString(PasswordRepository.getRepositoryDirectory())
} }
lifecycleScope.launch { lifecycleScope.launch {
@ -561,13 +543,13 @@ class PasswordStore : BaseGitActivity() {
} }
/** /**
* Prompt the user with a new category name to assign, * Prompt the user with a new category name to assign, if the new category forms/leads a path
* if the new category forms/leads a path (i.e. contains "/"), intermediate directories will be created * (i.e. contains "/"), intermediate directories will be created and new category will be placed
* and new category will be placed inside. * inside.
* *
* @param oldCategory The category to change its name * @param oldCategory The category to change its name
* @param error Determines whether to show an error to the user in the alert dialog, * @param error Determines whether to show an error to the user in the alert dialog, this error
* this error may be due to the new category the user entered already exists or the field was empty or the * may be due to the new category the user entered already exists or the field was empty or the
* destination path is outside the repository * destination path is outside the repository
* *
* @see [CategoryRenameError] * @see [CategoryRenameError]
@ -581,7 +563,8 @@ class PasswordStore : BaseGitActivity() {
newCategoryEditText.error = getString(error.resource) newCategoryEditText.error = getString(error.resource)
} }
val dialog = MaterialAlertDialogBuilder(this) val dialog =
MaterialAlertDialogBuilder(this)
.setTitle(R.string.title_rename_folder) .setTitle(R.string.title_rename_folder)
.setView(view) .setView(view)
.setMessage(getString(R.string.message_rename_folder, oldCategory.name)) .setMessage(getString(R.string.message_rename_folder, oldCategory.name))
@ -591,10 +574,12 @@ class PasswordStore : BaseGitActivity() {
newCategoryEditText.text.isNullOrBlank() -> renameCategory(oldCategory, CategoryRenameError.EmptyField) newCategoryEditText.text.isNullOrBlank() -> renameCategory(oldCategory, CategoryRenameError.EmptyField)
newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists) newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists)
!newCategory.isInsideRepository() -> renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo) !newCategory.isInsideRepository() -> renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo)
else -> lifecycleScope.launch(Dispatchers.IO) { else ->
lifecycleScope.launch(Dispatchers.IO) {
moveFile(oldCategory.file, newCategory) moveFile(oldCategory.file, newCategory)
//associate the new category with the last category's timestamp in history // associate the new category with the last category's timestamp in
// history
val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) val preference = getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
val timestamp = preference.getString(oldCategory.file.absolutePath.base64()) val timestamp = preference.getString(oldCategory.file.absolutePath.base64())
if (timestamp != null) { if (timestamp != null) {
@ -627,10 +612,10 @@ class PasswordStore : BaseGitActivity() {
/** /**
* Refreshes the password list by re-executing the last navigation or search action, preserving * Refreshes the password list by re-executing the last navigation or search action, preserving
* the navigation stack and scroll position. If the current directory no longer exists, * the navigation stack and scroll position. If the current directory no longer exists, navigation
* navigation is reset to the repository root. If the optional [target] argument is provided, * is reset to the repository root. If the optional [target] argument is provided, it will be
* it will be entered if it is a directory or scrolled into view if it is a file (both inside * entered if it is a directory or scrolled into view if it is a file (both inside the current
* the current directory). * directory).
*/ */
fun refreshPasswordList(target: File? = null) { fun refreshPasswordList(target: File? = null) {
val plist = getPasswordFragment() val plist = getPasswordFragment()
@ -651,7 +636,8 @@ class PasswordStore : BaseGitActivity() {
get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory() get() = getPasswordFragment()?.currentDir ?: PasswordRepository.getRepositoryDirectory()
private suspend fun moveFile(source: File, destinationFile: File) { private suspend fun moveFile(source: File, destinationFile: File) {
val sourceDestinationMap = if (source.isDirectory) { val sourceDestinationMap =
if (source.isDirectory) {
destinationFile.mkdirs() destinationFile.mkdirs()
// Recursively list all files (not directories) below `source`, then // Recursively list all files (not directories) below `source`, then
// obtain the corresponding target file by resolving the relative path // obtain the corresponding target file by resolving the relative path
@ -676,7 +662,9 @@ class PasswordStore : BaseGitActivity() {
} }
fun matchPasswordWithApp(item: PasswordItem) { fun matchPasswordWithApp(item: PasswordItem) {
val path = item.file val path =
item
.file
.absolutePath .absolutePath
.replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "") .replace(PasswordRepository.getRepositoryDirectory().toString() + "/", "")
.replace(".gpg", "") .replace(".gpg", "")
@ -695,8 +683,7 @@ class PasswordStore : BaseGitActivity() {
const val REQUEST_ARG_PATH = "PATH" const val REQUEST_ARG_PATH = "PATH"
private fun isPrintable(c: Char): Boolean { private fun isPrintable(c: Char): Boolean {
val block = UnicodeBlock.of(c) val block = UnicodeBlock.of(c)
return (!Character.isISOControl(c) && return (!Character.isISOControl(c) && block != null && block !== UnicodeBlock.SPECIALS)
block != null && block !== UnicodeBlock.SPECIALS)
} }
} }
} }

View file

@ -36,14 +36,13 @@ class ProxySelectorActivity : AppCompatActivity() {
with(binding) { with(binding) {
proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST)) proxyHost.setText(proxyPrefs.getString(PreferenceKeys.PROXY_HOST))
proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME)) proxyUser.setText(proxyPrefs.getString(PreferenceKeys.PROXY_USERNAME))
proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let { proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let { proxyPort.setText("$it") }
proxyPort.setText("$it")
}
proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD)) proxyPassword.setText(proxyPrefs.getString(PreferenceKeys.PROXY_PASSWORD))
save.setOnClickListener { saveSettings() } save.setOnClickListener { saveSettings() }
proxyHost.doOnTextChanged { text, _, _, _ -> proxyHost.doOnTextChanged { text, _, _, _ ->
if (text != null) { if (text != null) {
proxyHost.error = if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) { proxyHost.error =
if (text.matches(IP_ADDRESS_REGEX) || text.matches(WEB_ADDRESS_REGEX)) {
null null
} else { } else {
getString(R.string.invalid_proxy_url) getString(R.string.invalid_proxy_url)
@ -51,23 +50,14 @@ class ProxySelectorActivity : AppCompatActivity() {
} }
} }
} }
} }
private fun saveSettings() { private fun saveSettings() {
proxyPrefs.edit { proxyPrefs.edit {
binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let { binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyHost = it }
GitSettings.proxyHost = it binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyUsername = it }
} binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let { GitSettings.proxyPort = it.toInt() }
binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let { binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyPassword = it }
GitSettings.proxyUsername = it
}
binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let {
GitSettings.proxyPort = it.toInt()
}
binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let {
GitSettings.proxyPassword = it
}
} }
ProxyUtils.setDefaultProxy() ProxyUtils.setDefaultProxy()
Handler(Looper.getMainLooper()).postDelayed(500) { finish() } Handler(Looper.getMainLooper()).postDelayed(500) { finish() }

View file

@ -46,35 +46,35 @@ class AutofillSettings(private val activity: FragmentActivity) : SettingsProvide
Lifecycle.Event.ON_RESUME -> { Lifecycle.Event.ON_RESUME -> {
pref.checked = isAutofillServiceEnabled pref.checked = isAutofillServiceEnabled
} }
else -> { else -> {}
}
} }
} }
MaterialAlertDialogBuilder(activity).run { MaterialAlertDialogBuilder(activity).run {
setTitle(R.string.pref_autofill_enable_title) setTitle(R.string.pref_autofill_enable_title)
@SuppressLint("InflateParams") @SuppressLint("InflateParams")
val layout = val layout = activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null)
activity.layoutInflater.inflate(R.layout.oreo_autofill_instructions, null) val supportedBrowsersTextView = layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
val supportedBrowsersTextView =
layout.findViewById<AppCompatTextView>(R.id.supportedBrowsers)
supportedBrowsersTextView.text = supportedBrowsersTextView.text =
getInstalledBrowsersWithAutofillSupportLevel(context).joinToString( getInstalledBrowsersWithAutofillSupportLevel(context).joinToString(separator = "\n") {
separator = "\n"
) {
val appLabel = it.first val appLabel = it.first
val supportDescription = when (it.second) { val supportDescription =
when (it.second) {
BrowserAutofillSupportLevel.None -> activity.getString(R.string.oreo_autofill_no_support) BrowserAutofillSupportLevel.None -> activity.getString(R.string.oreo_autofill_no_support)
BrowserAutofillSupportLevel.FlakyFill -> activity.getString(R.string.oreo_autofill_flaky_fill_support) BrowserAutofillSupportLevel.FlakyFill -> activity.getString(R.string.oreo_autofill_flaky_fill_support)
BrowserAutofillSupportLevel.PasswordFill -> activity.getString(R.string.oreo_autofill_password_fill_support) BrowserAutofillSupportLevel.PasswordFill ->
BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility -> activity.getString(R.string.oreo_autofill_password_fill_and_conditional_save_support) 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.GeneralFill -> activity.getString(R.string.oreo_autofill_general_fill_support)
BrowserAutofillSupportLevel.GeneralFillAndSave -> activity.getString(R.string.oreo_autofill_general_fill_and_save_support) BrowserAutofillSupportLevel.GeneralFillAndSave ->
activity.getString(R.string.oreo_autofill_general_fill_and_save_support)
} }
"$appLabel: $supportDescription" "$appLabel: $supportDescription"
} }
setView(layout) setView(layout)
setPositiveButton(R.string.dialog_ok) { _, _ -> setPositiveButton(R.string.dialog_ok) { _, _ ->
val intent = Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply { val intent =
Intent(Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE).apply {
data = Uri.parse("package:${BuildConfig.APPLICATION_ID}") data = Uri.parse("package:${BuildConfig.APPLICATION_ID}")
} }
activity.startActivity(intent) activity.startActivity(intent)

View file

@ -21,7 +21,8 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class DirectorySelectionActivity : AppCompatActivity() { class DirectorySelectionActivity : AppCompatActivity() {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private val directorySelectAction = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? -> private val directorySelectAction =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri: Uri? ->
if (uri == null) return@registerForActivityResult if (uri == null) return@registerForActivityResult
d { "Selected repository URI is $uri" } d { "Selected repository URI is $uri" }

View file

@ -61,7 +61,8 @@ class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider
checkBox(PreferenceKeys.BIOMETRIC_AUTH) { checkBox(PreferenceKeys.BIOMETRIC_AUTH) {
titleRes = R.string.pref_biometric_auth_title titleRes = R.string.pref_biometric_auth_title
defaultValue = false defaultValue = false
}.apply { }
.apply {
val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity) val canAuthenticate = BiometricAuthenticator.canAuthenticate(activity)
if (!canAuthenticate) { if (!canAuthenticate) {
enabled = false enabled = false
@ -81,7 +82,8 @@ class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider
enabled = true enabled = true
} }
else -> { else -> {
// If any error occurs, revert back to the previous state. This // If any error occurs, revert back to the previous
// state. This
// catch-all clause includes the cancellation case. // catch-all clause includes the cancellation case.
putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked) putBoolean(PreferenceKeys.BIOMETRIC_AUTH, !checked)
checked = !isChecked checked = !isChecked

View file

@ -23,21 +23,26 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
class MiscSettings(activity: FragmentActivity) : SettingsProvider { class MiscSettings(activity: FragmentActivity) : SettingsProvider {
private val storeExportAction = activity.registerForActivityResult(object : ActivityResultContracts.OpenDocumentTree() { private val storeExportAction =
activity.registerForActivityResult(
object : ActivityResultContracts.OpenDocumentTree() {
override fun createIntent(context: Context, input: Uri?): Intent { override fun createIntent(context: Context, input: Uri?): Intent {
return super.createIntent(context, input).apply { return super.createIntent(context, input).apply {
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or flags =
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
} }
} }
}) { uri: Uri? -> }
) { uri: Uri? ->
if (uri == null) return@registerForActivityResult if (uri == null) return@registerForActivityResult
val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri) val targetDirectory = DocumentFile.fromTreeUri(activity.applicationContext, uri)
if (targetDirectory != null) { if (targetDirectory != null) {
val service = Intent(activity.applicationContext, PasswordExportService::class.java).apply { val service =
Intent(activity.applicationContext, PasswordExportService::class.java).apply {
action = PasswordExportService.ACTION_EXPORT_PASSWORD action = PasswordExportService.ACTION_EXPORT_PASSWORD
putExtra("uri", uri) putExtra("uri", uri)
} }

View file

@ -30,14 +30,16 @@ import java.io.File
class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider { class PasswordSettings(private val activity: FragmentActivity) : SettingsProvider {
private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs } private val sharedPrefs by lazy(LazyThreadSafetyMode.NONE) { activity.sharedPrefs }
private val storeCustomXkpwdDictionaryAction = activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> private val storeCustomXkpwdDictionaryAction =
activity.registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@registerForActivityResult if (uri == null) return@registerForActivityResult
Toast.makeText( Toast.makeText(
activity, activity,
activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path), activity.resources.getString(R.string.xkpwgen_custom_dict_imported, uri.path),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() )
.show()
sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) } sharedPrefs.edit { putString(PreferenceKeys.PREF_KEY_CUSTOM_DICT, uri.toString()) }
@ -50,7 +52,8 @@ class PasswordSettings(private val activity: FragmentActivity) : SettingsProvide
override fun provideSettings(builder: PreferenceScreen.Builder) { override fun provideSettings(builder: PreferenceScreen.Builder) {
builder.apply { builder.apply {
val customDictPref = CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply { val customDictPref =
CheckBoxPreference(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT).apply {
titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title titleRes = R.string.pref_xkpwgen_custom_wordlist_enabled_title
summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off summaryRes = R.string.pref_xkpwgen_custom_dict_summary_off
summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on summaryOnRes = R.string.pref_xkpwgen_custom_dict_summary_on
@ -60,10 +63,12 @@ class PasswordSettings(private val activity: FragmentActivity) : SettingsProvide
true true
} }
} }
val customDictPathPref = Preference(PreferenceKeys.PREF_KEY_CUSTOM_DICT).apply { val customDictPathPref =
Preference(PreferenceKeys.PREF_KEY_CUSTOM_DICT).apply {
dependency = PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT dependency = PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT
titleRes = R.string.pref_xkpwgen_custom_dict_picker_title titleRes = R.string.pref_xkpwgen_custom_dict_picker_title
summary = sharedPrefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) summary =
sharedPrefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT)
?: activity.resources.getString(R.string.pref_xkpwgen_custom_dict_picker_summary) ?: activity.resources.getString(R.string.pref_xkpwgen_custom_dict_picker_summary)
visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd" visible = sharedPrefs.getString(PreferenceKeys.PREF_KEY_PWGEN_TYPE) == "xkpasswd"
onClick { onClick {

View file

@ -47,9 +47,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi
MaterialAlertDialogBuilder(activity) MaterialAlertDialogBuilder(activity)
.setTitle(activity.resources.getString(R.string.external_repository_dialog_title)) .setTitle(activity.resources.getString(R.string.external_repository_dialog_title))
.setMessage(activity.resources.getString(R.string.external_repository_dialog_text)) .setMessage(activity.resources.getString(R.string.external_repository_dialog_text))
.setPositiveButton(R.string.dialog_ok) { _, _ -> .setPositiveButton(R.string.dialog_ok) { _, _ -> launchActivity(DirectorySelectionActivity::class.java) }
launchActivity(DirectorySelectionActivity::class.java)
}
.setNegativeButton(R.string.dialog_cancel, null) .setNegativeButton(R.string.dialog_cancel, null)
.show() .show()
} }
@ -132,15 +130,15 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi
} }
pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) { pref(PreferenceKeys.SSH_OPENKEYSTORE_CLEAR_KEY_ID) {
titleRes = R.string.pref_title_openkeystore_clear_keyid titleRes = R.string.pref_title_openkeystore_clear_keyid
visible = activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() visible = activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty() ?: false
?: false
onClick { onClick {
activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) } activity.sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) }
visible = false visible = false
true true
} }
} }
val deleteRepoPref = pref(PreferenceKeys.GIT_DELETE_REPO) { val deleteRepoPref =
pref(PreferenceKeys.GIT_DELETE_REPO) {
titleRes = R.string.pref_git_delete_repo_title titleRes = R.string.pref_git_delete_repo_title
summaryRes = R.string.pref_git_delete_repo_summary summaryRes = R.string.pref_git_delete_repo_summary
visible = !activity.sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false) visible = !activity.sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL, false)
@ -154,11 +152,8 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi
runCatching { runCatching {
PasswordRepository.getRepositoryDirectory().deleteRecursively() PasswordRepository.getRepositoryDirectory().deleteRecursively()
PasswordRepository.closeRepository() PasswordRepository.closeRepository()
}.onFailure {
it.message?.let { message ->
activity.snackbar(message = message)
}
} }
.onFailure { it.message?.let { message -> activity.snackbar(message = message) } }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
activity.getSystemService<ShortcutManager>()?.apply { activity.getSystemService<ShortcutManager>()?.apply {
@ -169,7 +164,9 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi
dialogInterface.cancel() dialogInterface.cancel()
activity.finish() activity.finish()
} }
.setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ -> run { dialogInterface.cancel() } } .setNegativeButton(R.string.dialog_do_not_delete) { dialogInterface, _ ->
run { dialogInterface.cancel() }
}
.show() .show()
true true
} }

View file

@ -30,7 +30,8 @@ class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
val screen = screen(this) { val screen =
screen(this) {
subScreen { subScreen {
titleRes = R.string.pref_category_general_title titleRes = R.string.pref_category_general_title
iconRes = R.drawable.app_settings_alt_24px iconRes = R.drawable.app_settings_alt_24px
@ -58,15 +59,16 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
val adapter = PreferencesAdapter(screen) val adapter = PreferencesAdapter(screen)
adapter.onScreenChangeListener = PreferencesAdapter.OnScreenChangeListener { subScreen, entering -> adapter.onScreenChangeListener =
supportActionBar?.title = if (!entering) { PreferencesAdapter.OnScreenChangeListener { subScreen, entering ->
supportActionBar?.title =
if (!entering) {
getString(R.string.action_settings) getString(R.string.action_settings)
} else { } else {
getString(subScreen.titleRes) getString(subScreen.titleRes)
} }
} }
savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter") savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter")?.let(adapter::loadSavedState)
?.let(adapter::loadSavedState)
binding.preferenceRecyclerView.adapter = adapter binding.preferenceRecyclerView.adapter = adapter
} }
@ -77,7 +79,8 @@ class SettingsActivity : AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
android.R.id.home -> if (!preferencesAdapter.goBack()) { android.R.id.home ->
if (!preferencesAdapter.goBack()) {
super.onOptionsItemSelected(item) super.onOptionsItemSelected(item)
} else { } else {
true true
@ -87,7 +90,6 @@ class SettingsActivity : AppCompatActivity() {
} }
override fun onBackPressed() { override fun onBackPressed() {
if (!preferencesAdapter.goBack()) if (!preferencesAdapter.goBack()) super.onBackPressed()
super.onBackPressed()
} }
} }

View file

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

View file

@ -20,11 +20,10 @@ class ShowSshKeyFragment : DialogFragment() {
return MaterialAlertDialogBuilder(requireActivity()).run { return MaterialAlertDialogBuilder(requireActivity()).run {
setMessage(getString(R.string.ssh_keygen_message, publicKey)) setMessage(getString(R.string.ssh_keygen_message, publicKey))
setTitle(R.string.your_public_key) setTitle(R.string.your_public_key)
setNegativeButton(R.string.ssh_keygen_later) { _, _ -> setNegativeButton(R.string.ssh_keygen_later) { _, _ -> (activity as? SshKeyGenActivity)?.finish() }
(activity as? SshKeyGenActivity)?.finish()
}
setPositiveButton(R.string.ssh_keygen_share) { _, _ -> setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
val sendIntent = Intent().apply { val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND action = Intent.ACTION_SEND
type = "text/plain" type = "text/plain"
putExtra(Intent.EXTRA_TEXT, publicKey) putExtra(Intent.EXTRA_TEXT, publicKey)

View file

@ -30,15 +30,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) {
Rsa({ requireAuthentication -> Rsa({ requireAuthentication -> SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) }),
SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) Ecdsa({ requireAuthentication -> SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) }),
}), Ed25519({ requireAuthentication -> SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) }),
Ecdsa({ requireAuthentication ->
SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication)
}),
Ed25519({ requireAuthentication ->
SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication)
}),
} }
class SshKeyGenActivity : AppCompatActivity() { class SshKeyGenActivity : AppCompatActivity() {
@ -56,37 +50,32 @@ class SshKeyGenActivity : AppCompatActivity() {
MaterialAlertDialogBuilder(this@SshKeyGenActivity).run { MaterialAlertDialogBuilder(this@SshKeyGenActivity).run {
setTitle(R.string.ssh_keygen_existing_title) setTitle(R.string.ssh_keygen_existing_title)
setMessage(R.string.ssh_keygen_existing_message) setMessage(R.string.ssh_keygen_existing_message)
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> lifecycleScope.launch { generate() } }
lifecycleScope.launch { setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
generate()
}
}
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ ->
finish()
}
show() show()
} }
} else { } else {
lifecycleScope.launch { lifecycleScope.launch { generate() }
generate()
}
} }
} }
keyTypeGroup.check(R.id.key_type_ecdsa) keyTypeGroup.check(R.id.key_type_ecdsa)
keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa)
keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (isChecked) { if (isChecked) {
keyGenType = when (checkedId) { keyGenType =
when (checkedId) {
R.id.key_type_ed25519 -> KeyGenType.Ed25519 R.id.key_type_ed25519 -> KeyGenType.Ed25519
R.id.key_type_ecdsa -> KeyGenType.Ecdsa R.id.key_type_ecdsa -> KeyGenType.Ecdsa
R.id.key_type_rsa -> KeyGenType.Rsa R.id.key_type_rsa -> KeyGenType.Rsa
else -> throw IllegalStateException("Impossible key type selection") else -> throw IllegalStateException("Impossible key type selection")
} }
keyTypeExplanation.setText(when (keyGenType) { keyTypeExplanation.setText(
when (keyGenType) {
KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519 KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519
KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa
KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa
}) }
)
} }
} }
keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure keyRequireAuthentication.isEnabled = keyguardManager.isDeviceSecure
@ -114,11 +103,13 @@ class SshKeyGenActivity : AppCompatActivity() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val requireAuthentication = binding.keyRequireAuthentication.isChecked val requireAuthentication = binding.keyRequireAuthentication.isChecked
if (requireAuthentication) { if (requireAuthentication) {
val result = withContext(Dispatchers.Main) { val result =
withContext(Dispatchers.Main) {
suspendCoroutine<BiometricAuthenticator.Result> { cont -> suspendCoroutine<BiometricAuthenticator.Result> { cont ->
BiometricAuthenticator.authenticate(this@SshKeyGenActivity, R.string.biometric_prompt_title_ssh_keygen) { BiometricAuthenticator.authenticate(
cont.resume(it) this@SshKeyGenActivity,
} R.string.biometric_prompt_title_ssh_keygen
) { cont.resume(it) }
} }
} }
if (result !is BiometricAuthenticator.Result.Success) if (result !is BiometricAuthenticator.Result.Success)
@ -127,17 +118,13 @@ class SshKeyGenActivity : AppCompatActivity() {
keyGenType.generateKey(requireAuthentication) keyGenType.generateKey(requireAuthentication)
} }
} }
getEncryptedGitPrefs().edit { getEncryptedGitPrefs().edit { remove("ssh_key_local_passphrase") }
remove("ssh_key_local_passphrase")
}
binding.generate.apply { binding.generate.apply {
text = getString(R.string.ssh_keygen_generate) text = getString(R.string.ssh_keygen_generate)
isEnabled = true isEnabled = true
} }
result.fold( result.fold(
success = { success = { ShowSshKeyFragment().show(supportFragmentManager, "public_key") },
ShowSshKeyFragment().show(supportFragmentManager, "public_key")
},
failure = { e -> failure = { e ->
e.printStackTrace() e.printStackTrace()
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)

View file

@ -18,7 +18,8 @@ import dev.msfjarvis.aps.util.git.sshj.SshKey
class SshKeyImportActivity : AppCompatActivity() { class SshKeyImportActivity : AppCompatActivity() {
private val sshKeyImportAction = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> private val sshKeyImportAction =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
if (uri == null) { if (uri == null) {
finish() finish()
return@registerForActivityResult return@registerForActivityResult
@ -28,7 +29,8 @@ class SshKeyImportActivity : AppCompatActivity() {
Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show() Toast.makeText(this, resources.getString(R.string.ssh_key_success_dialog_title), Toast.LENGTH_LONG).show()
setResult(RESULT_OK) setResult(RESULT_OK)
finish() finish()
}.onFailure { e -> }
.onFailure { e ->
MaterialAlertDialogBuilder(this) MaterialAlertDialogBuilder(this)
.setTitle(resources.getString(R.string.ssh_key_error_dialog_title)) .setTitle(resources.getString(R.string.ssh_key_error_dialog_title))
.setMessage(e.message) .setMessage(e.message)
@ -43,9 +45,7 @@ class SshKeyImportActivity : AppCompatActivity() {
MaterialAlertDialogBuilder(this).run { MaterialAlertDialogBuilder(this).run {
setTitle(R.string.ssh_keygen_existing_title) setTitle(R.string.ssh_keygen_existing_title)
setMessage(R.string.ssh_keygen_existing_message) setMessage(R.string.ssh_keygen_existing_message)
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> importSshKey() }
importSshKey()
}
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() } setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
setOnCancelListener { finish() } setOnCancelListener { finish() }
show() show()

View file

@ -37,21 +37,27 @@ object BiometricAuthenticator {
@StringRes dialogTitleRes: Int = R.string.biometric_prompt_title, @StringRes dialogTitleRes: Int = R.string.biometric_prompt_title,
callback: (Result) -> Unit callback: (Result) -> Unit
) { ) {
val authCallback = object : BiometricPrompt.AuthenticationCallback() { val authCallback =
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString) super.onAuthenticationError(errorCode, errString)
tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" } tag(TAG).d { "BiometricAuthentication error: errorCode=$errorCode, msg=$errString" }
callback(when (errorCode) { callback(
BiometricPrompt.ERROR_CANCELED, BiometricPrompt.ERROR_USER_CANCELED, when (errorCode) {
BiometricPrompt.ERROR_CANCELED,
BiometricPrompt.ERROR_USER_CANCELED,
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> { BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
Result.Cancelled Result.Cancelled
} }
BiometricPrompt.ERROR_HW_NOT_PRESENT, BiometricPrompt.ERROR_HW_UNAVAILABLE, BiometricPrompt.ERROR_HW_NOT_PRESENT,
BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> { BiometricPrompt.ERROR_HW_UNAVAILABLE,
BiometricPrompt.ERROR_NO_BIOMETRICS,
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
Result.HardwareUnavailableOrDisabled Result.HardwareUnavailableOrDisabled
} }
else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString)) else -> Result.Failure(errorCode, activity.getString(R.string.biometric_auth_error_reason, errString))
}) }
)
} }
override fun onAuthenticationFailed() { override fun onAuthenticationFailed() {
@ -66,11 +72,13 @@ object BiometricAuthenticator {
} }
val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true val deviceHasKeyguard = activity.getSystemService<KeyguardManager>()?.isDeviceSecure == true
if (canAuthenticate(activity) || deviceHasKeyguard) { if (canAuthenticate(activity) || deviceHasKeyguard) {
val promptInfo = BiometricPrompt.PromptInfo.Builder() val promptInfo =
BiometricPrompt.PromptInfo.Builder()
.setTitle(activity.getString(dialogTitleRes)) .setTitle(activity.getString(dialogTitleRes))
.setAllowedAuthenticators(validAuthenticators) .setAllowedAuthenticators(validAuthenticators)
.build() .build()
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback).authenticate(promptInfo) BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback)
.authenticate(promptInfo)
} else { } else {
callback(Result.HardwareUnavailableOrDisabled) callback(Result.HardwareUnavailableOrDisabled)
} }

View file

@ -27,9 +27,7 @@ import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
import java.io.File import java.io.File
/** /** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
* Implements [AutofillResponseBuilder]'s methods for API 30 and above
*/
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
class Api30AutofillResponseBuilder(form: FillableForm) { class Api30AutofillResponseBuilder(form: FillableForm) {
@ -73,8 +71,7 @@ class Api30AutofillResponseBuilder(form: FillableForm) {
private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
val metadata = makeSearchAndFillMetadata(context) val metadata = makeSearchAndFillMetadata(context)
val intentSender = val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec) return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec)
} }
@ -85,7 +82,6 @@ class Api30AutofillResponseBuilder(form: FillableForm) {
return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec) return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec)
} }
private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
@ -105,8 +101,11 @@ class Api30AutofillResponseBuilder(form: FillableForm) {
// autofilled for the first time. This `FillResponse` needs to be returned as a result from // 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. // `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
val fillResponseAfterReset = makeFillResponse(context, null, emptyList()) val fillResponseAfterReset = makeFillResponse(context, null, emptyList())
val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender( val intentSender =
context, publisherChangedException, fillResponseAfterReset AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
context,
publisherChangedException,
fillResponseAfterReset
) )
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
} }
@ -124,7 +123,11 @@ class Api30AutofillResponseBuilder(form: FillableForm) {
} }
} }
private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List<File>): FillResponse? { private fun makeFillResponse(
context: Context,
inlineSuggestionsRequest: InlineSuggestionsRequest?,
matchedFiles: List<File>
): FillResponse? {
var datasetCount = 0 var datasetCount = 0
val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList() val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
return FillResponse.Builder().run { return FillResponse.Builder().run {
@ -156,7 +159,8 @@ class Api30AutofillResponseBuilder(form: FillableForm) {
} }
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE // 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 // See:
// https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
private fun makeSaveInfo(): SaveInfo? { private fun makeSaveInfo(): SaveInfo? {
if (!canBeSaved) return null if (!canBeSaved) return null
check(saveFlags != null) check(saveFlags != null)
@ -172,11 +176,10 @@ class Api30AutofillResponseBuilder(form: FillableForm) {
} }
} }
/** /** Creates and returns a suitable [FillResponse] to the Autofill framework. */
* Creates and returns a suitable [FillResponse] to the Autofill framework.
*/
fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) { fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) {
AutofillMatcher.getMatchesFor(context, formOrigin).fold( AutofillMatcher.getMatchesFor(context, formOrigin)
.fold(
success = { matchedFiles -> success = { matchedFiles ->
callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles)) callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
}, },

View file

@ -42,9 +42,7 @@ class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
} }
} }
/** /** Manages "matches", i.e., associations between apps or websites and Password Store entries. */
* Manages "matches", i.e., associations between apps or websites and Password Store entries.
*/
class AutofillMatcher { class AutofillMatcher {
companion object { companion object {
@ -52,12 +50,10 @@ class AutofillMatcher {
private const val MAX_NUM_MATCHES = 10 private const val MAX_NUM_MATCHES = 10
private const val PREFERENCE_PREFIX_TOKEN = "token;" private const val PREFERENCE_PREFIX_TOKEN = "token;"
private fun tokenKey(formOrigin: FormOrigin.App) = private fun tokenKey(formOrigin: FormOrigin.App) = "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
"$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
private const val PREFERENCE_PREFIX_MATCHES = "matches;" private const val PREFERENCE_PREFIX_MATCHES = "matches;"
private fun matchesKey(formOrigin: FormOrigin) = private fun matchesKey(formOrigin: FormOrigin) = "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
"$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean { private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
return when (formOrigin) { return when (formOrigin) {
@ -65,9 +61,7 @@ class AutofillMatcher {
is FormOrigin.App -> { is FormOrigin.App -> {
val packageName = formOrigin.identifier val packageName = formOrigin.identifier
val certificatesHash = computeCertificatesHash(context, packageName) val certificatesHash = computeCertificatesHash(context, packageName)
val storedCertificatesHash = val storedCertificatesHash = context.autofillAppMatches.getString(tokenKey(formOrigin), null) ?: return false
context.autofillAppMatches.getString(tokenKey(formOrigin), null)
?: return false
val hashHasChanged = certificatesHash != storedCertificatesHash val hashHasChanged = certificatesHash != storedCertificatesHash
if (hashHasChanged) { if (hashHasChanged) {
e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" } e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
@ -83,9 +77,7 @@ class AutofillMatcher {
if (formOrigin is FormOrigin.App) { if (formOrigin is FormOrigin.App) {
val packageName = formOrigin.identifier val packageName = formOrigin.identifier
val certificatesHash = computeCertificatesHash(context, packageName) val certificatesHash = computeCertificatesHash(context, packageName)
context.autofillAppMatches.edit { context.autofillAppMatches.edit { putString(tokenKey(formOrigin), certificatesHash) }
putString(tokenKey(formOrigin), certificatesHash)
}
} }
// We don't need to store a hash for FormOrigin.Web since it can only originate from // We don't need to store a hash for FormOrigin.Web since it can only originate from
// browsers we trust to verify the origin. // browsers we trust to verify the origin.
@ -95,22 +87,21 @@ class AutofillMatcher {
* Get all Password Store entries that have already been associated with [formOrigin] by the * Get all Password Store entries that have already been associated with [formOrigin] by the
* user. * user.
* *
* If [formOrigin] represents an app and that app's certificates have changed since the * If [formOrigin] represents an app and that app's certificates have changed since the first
* first time the user associated an entry with it, an [AutofillPublisherChangedException] * time the user associated an entry with it, an [AutofillPublisherChangedException] will be
* will be thrown. * thrown.
*/ */
fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> { fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> {
if (hasFormOriginHashChanged(context, formOrigin)) { if (hasFormOriginHashChanged(context, formOrigin)) {
return Err(AutofillPublisherChangedException(formOrigin)) return Err(AutofillPublisherChangedException(formOrigin))
} }
val matchPreferences = context.matchPreferences(formOrigin) val matchPreferences = context.matchPreferences(formOrigin)
val matchedFiles = val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) } return Ok(
return Ok(matchedFiles.filter { it.exists() }.also { validFiles -> matchedFiles.filter { it.exists() }.also { validFiles ->
matchPreferences.edit { matchPreferences.edit { putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) }
putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet())
} }
}) )
} }
fun clearMatchesFor(context: Context, formOrigin: FormOrigin) { fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
@ -121,11 +112,11 @@ class AutofillMatcher {
} }
/** /**
* Associates the store entry [file] with [formOrigin], such that future Autofill responses * Associates the store entry [file] with [formOrigin], such that future Autofill responses to
* to requests from this app or website offer this entry as a dataset. * 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 * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of Android
* Android may crash when too many datasets are offered. * may crash when too many datasets are offered.
*/ */
fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) { fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
if (!file.exists()) return if (!file.exists()) return
@ -136,39 +127,39 @@ class AutofillMatcher {
throw AutofillPublisherChangedException(formOrigin) throw AutofillPublisherChangedException(formOrigin)
} }
val matchPreferences = context.matchPreferences(formOrigin) val matchPreferences = context.matchPreferences(formOrigin)
val matchedFiles = val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
val newFiles = setOf(file.absoluteFile).union(matchedFiles) val newFiles = setOf(file.absoluteFile).union(matchedFiles)
if (newFiles.size > MAX_NUM_MATCHES) { if (newFiles.size > MAX_NUM_MATCHES) {
Toast.makeText( Toast.makeText(
context, context,
context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES), context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
Toast.LENGTH_LONG Toast.LENGTH_LONG
).show() )
.show()
return return
} }
matchPreferences.edit { matchPreferences.edit { putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) }
putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet())
}
storeFormOriginHash(context, formOrigin) storeFormOriginHash(context, formOrigin)
d { "Stored match for $formOrigin" } d { "Stored match for $formOrigin" }
} }
/** /**
* Goes through all existing matches and updates their associated entries by using * Goes through all existing matches and updates their associated entries by using [moveFromTo]
* [moveFromTo] as a lookup table and deleting the matches for files in [delete]. * 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()) { fun updateMatches(
context: Context,
moveFromTo: Map<File, File> = emptyMap(),
delete: Collection<File> = emptyList()
) {
val deletePathList = delete.map { it.absolutePath } val deletePathList = delete.map { it.absolutePath }
val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath } val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }.mapKeys { it.key.absolutePath }
.mapKeys { it.key.absolutePath }
for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) { for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
for ((key, value) in prefs.all) { for ((key, value) in prefs.all) {
if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
// We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
// created with `putStringSet`. // created with `putStringSet`.
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST") val oldMatches = value as? Set<String>
val oldMatches = value as? Set<String>
if (oldMatches == null) { if (oldMatches == null) {
w { "Failed to read matches for $key" } w { "Failed to read matches for $key" }
continue continue
@ -176,16 +167,17 @@ class AutofillMatcher {
// Delete all matches for file locations that are going to be overwritten, then // Delete all matches for file locations that are going to be overwritten, then
// transfer matches over to the files at their new locations. // transfer matches over to the files at their new locations.
val newMatches = val newMatches =
oldMatches.asSequence() oldMatches
.asSequence()
.minus(deletePathList) .minus(deletePathList)
.minus(oldNewPathMap.values) .minus(oldNewPathMap.values)
.map { match -> .map { match ->
val newPath = oldNewPathMap[match] ?: return@map match val newPath = oldNewPathMap[match] ?: return@map match
d { "Updating match for $key: $match --> $newPath" } d { "Updating match for $key: $match --> $newPath" }
newPath newPath
}.toSet() }
if (newMatches != oldMatches) .toSet()
prefs.edit { putStringSet(key, newMatches) } if (newMatches != oldMatches) prefs.edit { putStringSet(key, newMatches) }
} }
} }
} }

View file

@ -31,7 +31,8 @@ enum class DirectoryStructure(val value: String) {
* - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased) * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
*/ */
fun getUsernameFor(file: File): String? = when (this) { fun getUsernameFor(file: File): String? =
when (this) {
EncryptedUsername -> null EncryptedUsername -> null
FileBased -> file.nameWithoutExtension FileBased -> file.nameWithoutExtension
DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
@ -51,15 +52,16 @@ enum class DirectoryStructure(val value: String) {
* - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased) * - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
* - Temporary PIN.gpg --> null (DirectoryBased) * - Temporary PIN.gpg --> null (DirectoryBased)
*/ */
fun getIdentifierFor(file: File): String? = when (this) { fun getIdentifierFor(file: File): String? =
when (this) {
EncryptedUsername -> file.nameWithoutExtension EncryptedUsername -> file.nameWithoutExtension
FileBased -> file.parentFile?.name ?: file.nameWithoutExtension FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
DirectoryBased -> file.parentFile?.parent DirectoryBased -> file.parentFile?.parent
} }
/** /**
* Returns the path components of [file] until right before the component that contains the * Returns the path components of [file] until right before the component that contains the origin
* origin identifier according to the current [DirectoryStructure]. * identifier according to the current [DirectoryStructure].
* *
* Examples: * Examples:
* - work/example.org.gpg --> work (EncryptedUsername) * - work/example.org.gpg --> work (EncryptedUsername)
@ -69,7 +71,8 @@ enum class DirectoryStructure(val value: String) {
* - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased) * - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
* - example.org/john@doe.org/password.gpg --> null (DirectoryBased) * - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
*/ */
fun getPathToIdentifierFor(file: File): String? = when (this) { fun getPathToIdentifierFor(file: File): String? =
when (this) {
EncryptedUsername -> file.parent EncryptedUsername -> file.parent
FileBased -> file.parentFile?.parent FileBased -> file.parentFile?.parent
DirectoryBased -> file.parentFile?.parentFile?.parent DirectoryBased -> file.parentFile?.parentFile?.parent
@ -77,7 +80,7 @@ enum class DirectoryStructure(val value: String) {
/** /**
* Returns the path component of [file] following the origin identifier according to the current * Returns the path component of [file] following the origin identifier according to the current
* [DirectoryStructure] (without file extension). * [DirectoryStructure](without file extension).
* *
* At least one of [DirectoryStructure.getIdentifierFor] and * At least one of [DirectoryStructure.getIdentifierFor] and
* [DirectoryStructure.getAccountPartFor] will always return a non-null result. * [DirectoryStructure.getAccountPartFor] will always return a non-null result.
@ -89,22 +92,24 @@ enum class DirectoryStructure(val value: String) {
* - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased) * - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
* - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback) * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
*/ */
fun getAccountPartFor(file: File): String? = when (this) { fun getAccountPartFor(file: File): String? =
when (this) {
EncryptedUsername -> null EncryptedUsername -> null
FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null } FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
DirectoryBased -> file.parentFile?.let { parentFile -> DirectoryBased -> file.parentFile?.let { parentFile -> "${parentFile.name}/${file.nameWithoutExtension}" }
"${parentFile.name}/${file.nameWithoutExtension}" ?: file.nameWithoutExtension
} ?: file.nameWithoutExtension
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) { fun getSaveFolderName(sanitizedIdentifier: String, username: String?) =
when (this) {
EncryptedUsername -> "/" EncryptedUsername -> "/"
FileBased -> sanitizedIdentifier FileBased -> sanitizedIdentifier
DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString() DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
} }
fun getSaveFileName(username: String?, identifier: String) = when (this) { fun getSaveFileName(username: String?, identifier: String) =
when (this) {
EncryptedUsername -> identifier EncryptedUsername -> identifier
FileBased -> username FileBased -> username
DirectoryBased -> "password" DirectoryBased -> "password"
@ -133,9 +138,7 @@ object AutofillPreferences {
directoryStructure: DirectoryStructure directoryStructure: DirectoryStructure
): Credentials { ): Credentials {
// Always give priority to a username stored in the encrypted extras // Always give priority to a username stored in the encrypted extras
val username = entry.username val username = entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
?: directoryStructure.getUsernameFor(file)
?: context.getDefaultUsername()
return Credentials(username, entry.password, entry.calculateTotpCode()) return Credentials(username, entry.password, entry.calculateTotpCode())
} }
} }

View file

@ -60,12 +60,10 @@ class AutofillResponseBuilder(form: FillableForm) {
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
} }
private fun makeSearchDataset(context: Context): Dataset? { private fun makeSearchDataset(context: Context): Dataset? {
if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
val metadata = makeSearchAndFillMetadata(context) val metadata = makeSearchAndFillMetadata(context)
val intentSender = val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata) return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
} }
@ -94,8 +92,11 @@ class AutofillResponseBuilder(form: FillableForm) {
// autofilled for the first time. This `FillResponse` needs to be returned as a result from // 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. // `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
val fillResponseAfterReset = makeFillResponse(context, emptyList()) val fillResponseAfterReset = makeFillResponse(context, emptyList())
val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender( val intentSender =
context, publisherChangedException, fillResponseAfterReset AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
context,
publisherChangedException,
fillResponseAfterReset
) )
return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata) return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
} }
@ -112,7 +113,8 @@ class AutofillResponseBuilder(form: FillableForm) {
} }
// TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE // 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 // See:
// https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
private fun makeSaveInfo(): SaveInfo? { private fun makeSaveInfo(): SaveInfo? {
if (!canBeSaved) return null if (!canBeSaved) return null
check(saveFlags != null) check(saveFlags != null)
@ -151,7 +153,9 @@ class AutofillResponseBuilder(form: FillableForm) {
} }
if (datasetCount == 0) return null if (datasetCount == 0) return null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))) setHeader(
makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))
)
} }
makeSaveInfo()?.let { setSaveInfo(it) } makeSaveInfo()?.let { setSaveInfo(it) }
setClientState(clientState) setClientState(clientState)
@ -160,14 +164,11 @@ class AutofillResponseBuilder(form: FillableForm) {
} }
} }
/** /** Creates and returns a suitable [FillResponse] to the Autofill framework. */
* Creates and returns a suitable [FillResponse] to the Autofill framework.
*/
fun fillCredentials(context: Context, callback: FillCallback) { fun fillCredentials(context: Context, callback: FillCallback) {
AutofillMatcher.getMatchesFor(context, formOrigin).fold( AutofillMatcher.getMatchesFor(context, formOrigin)
success = { matchedFiles -> .fold(
callback.onSuccess(makeFillResponse(context, matchedFiles)) success = { matchedFiles -> callback.onSuccess(makeFillResponse(context, matchedFiles)) },
},
failure = { e -> failure = { e ->
e(e) e(e)
callback.onSuccess(makePublisherChangedResponse(context, e)) callback.onSuccess(makePublisherChangedResponse(context, e))
@ -190,7 +191,8 @@ class AutofillResponseBuilder(form: FillableForm) {
// in. Otherwise, the entry in the cached list of datasets will be overwritten by the // 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 // 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. // the Autofill suggestions shown after the user clears the filled out form fields.
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val builder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
Dataset.Builder() Dataset.Builder()
} else { } else {
Dataset.Builder(makeRemoteView(context, makeEmptyMetadata())) Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))

View file

@ -42,19 +42,23 @@ fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews {
} }
@SuppressLint("RestrictedApi") @SuppressLint("RestrictedApi")
fun makeInlinePresentation(context: Context, imeSpec: InlinePresentationSpec, metadata: DatasetMetadata): InlinePresentation? { fun makeInlinePresentation(
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) context: Context,
return null imeSpec: InlinePresentationSpec,
metadata: DatasetMetadata
): InlinePresentation? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null
return null
val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0) val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0)
val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run { val slice =
InlineSuggestionUi.newContentBuilder(launchIntent).run {
setTitle(metadata.title) setTitle(metadata.title)
if (metadata.subtitle != null) if (metadata.subtitle != null) setSubtitle(metadata.subtitle)
setSubtitle(metadata.subtitle) setContentDescription(
setContentDescription(if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title) if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title
)
setStartIcon(Icon.createWithResource(context, metadata.iconRes)) setStartIcon(Icon.createWithResource(context, metadata.iconRes))
build().slice build().slice
} }
@ -62,52 +66,34 @@ fun makeInlinePresentation(context: Context, imeSpec: InlinePresentationSpec, me
return InlinePresentation(slice, imeSpec, false) return InlinePresentation(slice, imeSpec, false)
} }
fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata { fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata {
val directoryStructure = AutofillPreferences.directoryStructure(context) val directoryStructure = AutofillPreferences.directoryStructure(context)
val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
val title = directoryStructure.getIdentifierFor(relativeFile) val title = directoryStructure.getIdentifierFor(relativeFile) ?: directoryStructure.getAccountPartFor(relativeFile)!!
?: directoryStructure.getAccountPartFor(relativeFile)!!
val subtitle = directoryStructure.getAccountPartFor(relativeFile) val subtitle = directoryStructure.getAccountPartFor(relativeFile)
return DatasetMetadata( return DatasetMetadata(title, subtitle, R.drawable.ic_person_black_24dp)
title,
subtitle,
R.drawable.ic_person_black_24dp
)
} }
fun makeSearchAndFillMetadata(context: Context) = DatasetMetadata( fun makeSearchAndFillMetadata(context: Context) =
context.getString(R.string.oreo_autofill_search_in_store), DatasetMetadata(context.getString(R.string.oreo_autofill_search_in_store), null, R.drawable.ic_search_black_24dp)
null,
R.drawable.ic_search_black_24dp
)
fun makeGenerateAndFillMetadata(context: Context) = DatasetMetadata( fun makeGenerateAndFillMetadata(context: Context) =
DatasetMetadata(
context.getString(R.string.oreo_autofill_generate_password), context.getString(R.string.oreo_autofill_generate_password),
null, null,
R.drawable.ic_autofill_new_password R.drawable.ic_autofill_new_password
) )
fun makeFillOtpFromSmsMetadata(context: Context) = DatasetMetadata( fun makeFillOtpFromSmsMetadata(context: Context) =
context.getString(R.string.oreo_autofill_fill_otp_from_sms), DatasetMetadata(context.getString(R.string.oreo_autofill_fill_otp_from_sms), null, R.drawable.ic_autofill_sms)
null,
R.drawable.ic_autofill_sms
)
fun makeEmptyMetadata() = DatasetMetadata( fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
"PLACEHOLDER",
"PLACEHOLDER",
R.mipmap.ic_launcher
)
fun makeWarningMetadata(context: Context) = DatasetMetadata( fun makeWarningMetadata(context: Context) =
DatasetMetadata(
context.getString(R.string.oreo_autofill_warning_publisher_dataset_title), context.getString(R.string.oreo_autofill_warning_publisher_dataset_title),
context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary), context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary),
R.drawable.ic_warning_red_24dp R.drawable.ic_warning_red_24dp
) )
fun makeHeaderMetadata(title: String) = DatasetMetadata( fun makeHeaderMetadata(title: String) = DatasetMetadata(title, null, 0)
title,
null,
0
)

View file

@ -17,9 +17,7 @@ sealed class GpgIdentifier {
if (identifier.isEmpty()) return null if (identifier.isEmpty()) return null
// Match long key IDs: // Match long key IDs:
// FF22334455667788 or 0xFF22334455667788 // FF22334455667788 or 0xFF22334455667788
val maybeLongKeyId = identifier.removePrefix("0x").takeIf { val maybeLongKeyId = identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{16}".toRegex()) }
it.matches("[a-fA-F0-9]{16}".toRegex())
}
if (maybeLongKeyId != null) { if (maybeLongKeyId != null) {
val keyId = maybeLongKeyId.toULong(16) val keyId = maybeLongKeyId.toULong(16)
return KeyId(keyId.toLong()) return KeyId(keyId.toLong())
@ -27,11 +25,10 @@ sealed class GpgIdentifier {
// Match fingerprints: // Match fingerprints:
// FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899 // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
val maybeFingerprint = identifier.removePrefix("0x").takeIf { val maybeFingerprint = identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{40}".toRegex()) }
it.matches("[a-fA-F0-9]{40}".toRegex())
}
if (maybeFingerprint != null) { if (maybeFingerprint != null) {
// Truncating to the long key ID is not a security issue since OpenKeychain only accepts // Truncating to the long key ID is not a security issue since OpenKeychain only
// accepts
// non-ambiguous key IDs. // non-ambiguous key IDs.
val keyId = maybeFingerprint.takeLast(16).toULong(16) val keyId = maybeFingerprint.takeLast(16).toULong(16)
return KeyId(keyId.toLong()) return KeyId(keyId.toLong())

View file

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

View file

@ -12,36 +12,25 @@ import java.util.Date
import org.eclipse.jgit.lib.ObjectId import org.eclipse.jgit.lib.ObjectId
import org.eclipse.jgit.revwalk.RevCommit import org.eclipse.jgit.revwalk.RevCommit
/** /** The default OpenPGP provider for the app */
* The default OpenPGP provider for the app
*/
const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain" const val OPENPGP_PROVIDER = "org.sufficientlysecure.keychain"
/** /** Clears the given [flag] from the value of this [Int] */
* Clears the given [flag] from the value of this [Int]
*/
fun Int.clearFlag(flag: Int): Int { fun Int.clearFlag(flag: Int): Int {
return this and flag.inv() return this and flag.inv()
} }
/** /** Checks if this [Int] contains the given [flag] */
* Checks if this [Int] contains the given [flag]
*/
infix fun Int.hasFlag(flag: Int): Boolean { infix fun Int.hasFlag(flag: Int): Boolean {
return this and flag == flag return this and flag == flag
} }
/** /** Checks whether this [File] is a directory that contains [other]. */
* Checks whether this [File] is a directory that contains [other].
*/
fun File.contains(other: File): Boolean { fun File.contains(other: File): Boolean {
if (!isDirectory) if (!isDirectory) return false
return false if (!other.exists()) return false
if (!other.exists()) val relativePath =
return false runCatching { other.relativeTo(this) }.getOrElse {
val relativePath = runCatching {
other.relativeTo(this)
}.getOrElse {
return false return false
} }
// Direct containment is equivalent to the relative path being equal to the filename. // Direct containment is equivalent to the relative path being equal to the filename.
@ -49,16 +38,14 @@ fun File.contains(other: File): Boolean {
} }
/** /**
* Checks if this [File] is in the password repository directory as given * Checks if this [File] is in the password repository directory as given by
* by [PasswordRepository.getRepositoryDirectory] * [PasswordRepository.getRepositoryDirectory]
*/ */
fun File.isInsideRepository(): Boolean { fun File.isInsideRepository(): Boolean {
return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath) return canonicalPath.contains(PasswordRepository.getRepositoryDirectory().canonicalPath)
} }
/** /** Recursively lists the files in this [File], skipping any directories it encounters. */
* Recursively lists the files in this [File], skipping any directories it encounters.
*/
fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList() fun File.listFilesRecursively() = walkTopDown().filter { !it.isDirectory }.toList()
/** /**
@ -82,8 +69,8 @@ val RevCommit.time: Date
} }
/** /**
* Splits this [String] into an [Array] of [String]s, split on the UNIX LF line ending * Splits this [String] into an [Array] of [String] s, split on the UNIX LF line ending and stripped
* and stripped of any empty lines. * of any empty lines.
*/ */
fun String.splitLines(): Array<String> { fun String.splitLines(): Array<String> {
return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() return split("\n".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()

View file

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

View file

@ -5,7 +5,6 @@
package dev.msfjarvis.aps.util.extensions package dev.msfjarvis.aps.util.extensions
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@ -18,27 +17,30 @@ import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
/** /**
* Imported from https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c * Imported from
* https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
*/ */
class FragmentViewBindingDelegate<T : ViewBinding>( class FragmentViewBindingDelegate<T : ViewBinding>(val fragment: Fragment, val viewBindingFactory: (View) -> T) :
val fragment: Fragment, ReadOnlyProperty<Fragment, T> {
val viewBindingFactory: (View) -> T
) : ReadOnlyProperty<Fragment, T> {
private var binding: T? = null private var binding: T? = null
init { init {
fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { fragment.lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) { override fun onCreate(owner: LifecycleOwner) {
fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner -> fragment.viewLifecycleOwnerLiveData.observe(fragment) { viewLifecycleOwner ->
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { viewLifecycleOwner.lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) { override fun onDestroy(owner: LifecycleOwner) {
binding = null binding = null
} }
}) }
)
} }
} }
}) }
)
} }
override fun getValue(thisRef: Fragment, property: KProperty<*>): T { override fun getValue(thisRef: Fragment, property: KProperty<*>): T {
@ -60,6 +62,4 @@ fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
FragmentViewBindingDelegate(this, viewBindingFactory) FragmentViewBindingDelegate(this, viewBindingFactory)
inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) = inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) =
lazy(LazyThreadSafetyMode.NONE) { lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) }
bindingInflater.invoke(layoutInflater)
}

View file

@ -12,7 +12,7 @@ import dev.msfjarvis.aps.R
import java.net.UnknownHostException import java.net.UnknownHostException
/** /**
* Supertype for all Git-related [Exception]s that can be thrown by [GitCommandExecutor.execute]. * Supertype for all Git-related [Exception] s that can be thrown by [GitCommandExecutor.execute].
*/ */
sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) { sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) {
@ -20,21 +20,18 @@ sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(b
companion object { companion object {
private fun buildMessage(@StringRes res: Int, vararg fmt: String) = Application.instance.resources.getString(res, *fmt) private fun buildMessage(@StringRes res: Int, vararg fmt: String) =
Application.instance.resources.getString(res, *fmt)
} }
/** /** Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand]. */
* Encapsulates possible errors from a [org.eclipse.jgit.api.PullCommand].
*/
sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { sealed class PullException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error) object PullRebaseFailed : PullException(R.string.git_pull_rebase_fail_error)
object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error) object PullMergeFailed : PullException(R.string.git_pull_merge_fail_error)
} }
/** /** Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand]. */
* Encapsulates possible errors from a [org.eclipse.jgit.api.PushCommand].
*/
sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) { sealed class PushException(@StringRes res: Int, vararg fmt: String) : GitException(res, *fmt) {
object NonFastForward : PushException(R.string.git_push_nff_error) object NonFastForward : PushException(R.string.git_push_nff_error)

View file

@ -31,7 +31,8 @@ class GitCommandExecutor(
) { ) {
suspend fun execute(): Result<Unit, Throwable> { suspend fun execute(): Result<Unit, Throwable> {
val snackbar = activity.snackbar( val snackbar =
activity.snackbar(
message = activity.resources.getString(R.string.git_operation_running), message = activity.resources.getString(R.string.git_operation_running),
length = Snackbar.LENGTH_INDEFINITE, length = Snackbar.LENGTH_INDEFINITE,
) )
@ -41,9 +42,7 @@ class GitCommandExecutor(
for (command in operation.commands) { for (command in operation.commands) {
when (command) { when (command) {
is StatusCommand -> { is StatusCommand -> {
val res = withContext(Dispatchers.IO) { val res = withContext(Dispatchers.IO) { command.call() }
command.call()
}
nbChanges = res.uncommittedChanges.size nbChanges = res.uncommittedChanges.size
} }
is CommitCommand -> { is CommitCommand -> {
@ -58,9 +57,7 @@ class GitCommandExecutor(
} }
} }
is PullCommand -> { is PullCommand -> {
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) { command.call() }
command.call()
}
if (result.rebaseResult != null) { if (result.rebaseResult != null) {
if (!result.rebaseResult.status.isSuccessful) { if (!result.rebaseResult.status.isSuccessful) {
throw PullException.PullRebaseFailed throw PullException.PullRebaseFailed
@ -72,9 +69,7 @@ class GitCommandExecutor(
} }
} }
is PushCommand -> { is PushCommand -> {
val results = withContext(Dispatchers.IO) { val results = withContext(Dispatchers.IO) { command.call() }
command.call()
}
for (result in results) { for (result in results) {
// Code imported (modified) from Gerrit PushOp, license Apache v2 // Code imported (modified) from Gerrit PushOp, license Apache v2
for (rru in result.remoteUpdates) { for (rru in result.remoteUpdates) {
@ -83,8 +78,7 @@ class GitCommandExecutor(
RemoteRefUpdate.Status.REJECTED_NODELETE, RemoteRefUpdate.Status.REJECTED_NODELETE,
RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED, RemoteRefUpdate.Status.REJECTED_REMOTE_CHANGED,
RemoteRefUpdate.Status.NON_EXISTING, RemoteRefUpdate.Status.NON_EXISTING,
RemoteRefUpdate.Status.NOT_ATTEMPTED, RemoteRefUpdate.Status.NOT_ATTEMPTED, -> throw PushException.Generic(rru.status.name)
-> throw PushException.Generic(rru.status.name)
RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> { RemoteRefUpdate.Status.REJECTED_OTHER_REASON -> {
throw if ("non-fast-forward" == rru.message) { throw if ("non-fast-forward" == rru.message) {
PushException.RemoteRejected PushException.RemoteRejected
@ -98,24 +92,21 @@ class GitCommandExecutor(
activity.applicationContext, activity.applicationContext,
activity.applicationContext.getString(R.string.git_push_up_to_date), activity.applicationContext.getString(R.string.git_push_up_to_date),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() )
.show()
} }
} }
else -> { else -> {}
}
} }
} }
} }
} }
else -> { else -> {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { command.call() }
command.call()
} }
} }
} }
} }
}.also { .also { snackbar.dismiss() }
snackbar.dismiss()
}
} }
} }

View file

@ -20,16 +20,14 @@ private fun commits(): Iterable<RevCommit> {
e { "Could not access git repository" } e { "Could not access git repository" }
return listOf() return listOf()
} }
return runCatching { return runCatching { Git(repo).log().call() }.getOrElse { e ->
Git(repo).log().call()
}.getOrElse { e ->
e(e) { "Failed to obtain git commits" } e(e) { "Failed to obtain git commits" }
listOf() listOf()
} }
} }
/** /**
* Provides [GitCommit]s from a git-log of the password git repository. * Provides [GitCommit] s from a git-log of the password git repository.
* *
* All commits are acquired on the first request to this object. * All commits are acquired on the first request to this object.
*/ */
@ -42,9 +40,7 @@ class GitLogModel {
// Additionally, tests with 1000 commits in the log have not produced a significant delay in the // Additionally, tests with 1000 commits in the log have not produced a significant delay in the
// user experience. // user experience.
private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) { private val cache: MutableList<GitCommit> by lazy(LazyThreadSafetyMode.NONE) {
commits().map { commits().map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }.toMutableList()
GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time)
}.toMutableList()
} }
val size = cache.size val size = cache.size

View file

@ -14,7 +14,8 @@ import org.eclipse.jgit.lib.RepositoryState
class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) { class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
private val merging = repository.repositoryState == RepositoryState.MERGING private val merging = repository.repositoryState == RepositoryState.MERGING
private val resetCommands = arrayOf( private val resetCommands =
arrayOf(
// git checkout -b conflict-branch // git checkout -b conflict-branch
git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"), git.checkout().setCreateBranch(true).setName("conflicting-$remoteBranch-${System.currentTimeMillis()}"),
// push the changes // push the changes
@ -42,13 +43,13 @@ class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOp
} }
} }
override fun preExecute() = if (!git.repository.repositoryState.isRebasing && !merging) { override fun preExecute() =
if (!git.repository.repositoryState.isRebasing && !merging) {
MaterialAlertDialogBuilder(callingActivity) MaterialAlertDialogBuilder(callingActivity)
.setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title)) .setTitle(callingActivity.resources.getString(R.string.git_abort_and_push_title))
.setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded)) .setMessage(callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded))
.setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> .setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() }
callingActivity.finish() .show()
}.show()
false false
} else { } else {
true true

View file

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

View file

@ -24,10 +24,7 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
import kotlin.coroutines.resume import kotlin.coroutines.resume
class CredentialFinder( class CredentialFinder(val callingActivity: FragmentActivity, val authMode: AuthMode) : InteractivePasswordFinder() {
val callingActivity: FragmentActivity,
val authMode: AuthMode
) : InteractivePasswordFinder() {
override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) { override fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) {
val gitOperationPrefs = callingActivity.getEncryptedGitPrefs() val gitOperationPrefs = callingActivity.getEncryptedGitPrefs()
@ -54,14 +51,12 @@ class CredentialFinder(
} }
else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords") else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords")
} }
if (isRetry) if (isRetry) gitOperationPrefs.edit { remove(credentialPref) }
gitOperationPrefs.edit { remove(credentialPref) }
val storedCredential = gitOperationPrefs.getString(credentialPref, null) val storedCredential = gitOperationPrefs.getString(credentialPref, null)
if (storedCredential == null) { if (storedCredential == null) {
val layoutInflater = LayoutInflater.from(callingActivity) val layoutInflater = LayoutInflater.from(callingActivity)
@SuppressLint("InflateParams") @SuppressLint("InflateParams") val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout) val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential) val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
editCredential.setHint(hintRes) editCredential.setHint(hintRes)
@ -72,27 +67,23 @@ class CredentialFinder(
// Reset error when user starts entering a password // Reset error when user starts entering a password
editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null } editCredential.doOnTextChanged { _, _, _, _ -> credentialLayout.error = null }
} }
MaterialAlertDialogBuilder(callingActivity).run { MaterialAlertDialogBuilder(callingActivity)
.run {
setTitle(R.string.passphrase_dialog_title) setTitle(R.string.passphrase_dialog_title)
setMessage(messageRes) setMessage(messageRes)
setView(dialogView) setView(dialogView)
setPositiveButton(R.string.dialog_ok) { _, _ -> setPositiveButton(R.string.dialog_ok) { _, _ ->
val credential = editCredential.text.toString() val credential = editCredential.text.toString()
if (rememberCredential.isChecked) { if (rememberCredential.isChecked) {
gitOperationPrefs.edit { gitOperationPrefs.edit { putString(credentialPref, credential) }
putString(credentialPref, credential)
}
} }
cont.resume(credential) cont.resume(credential)
} }
setNegativeButton(R.string.dialog_cancel) { _, _ -> setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(null) }
cont.resume(null) setOnCancelListener { cont.resume(null) }
}
setOnCancelListener {
cont.resume(null)
}
create() create()
}.run { }
.run {
requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential) requestInputFocusOnView<TextInputEditText>(R.id.git_auth_credential)
show() show()
} }

View file

@ -57,7 +57,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
protected val repository = PasswordRepository.getRepository(null)!! protected val repository = PasswordRepository.getRepository(null)!!
protected val git = Git(repository) protected val git = Git(repository)
protected val remoteBranch = GitSettings.branch protected val remoteBranch = GitSettings.branch
private val authActivity get() = callingActivity as ContinuationContainerActivity private val authActivity
get() = callingActivity as ContinuationContainerActivity
private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() { private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() {
@ -70,10 +71,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
when (item) { when (item) {
is CredentialItem.Username -> item.value = uri?.user is CredentialItem.Username -> item.value = uri?.user
is CredentialItem.Password -> { is CredentialItem.Password -> {
item.value = cachedPassword?.clone() item.value =
?: passwordFinder.reqPassword(null).also { cachedPassword?.clone() ?: passwordFinder.reqPassword(null).also { cachedPassword = it.clone() }
cachedPassword = it.clone()
}
} }
else -> UnsupportedCredentialItem(uri, item.javaClass.name) else -> UnsupportedCredentialItem(uri, item.javaClass.name)
} }
@ -81,9 +80,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
return true return true
} }
override fun supports(vararg items: CredentialItem) = items.all { override fun supports(vararg items: CredentialItem) =
it is CredentialItem.Username || it is CredentialItem.Password items.all { it is CredentialItem.Username || it is CredentialItem.Password }
}
override fun reset(uri: URIish?) { override fun reset(uri: URIish?) {
cachedPassword?.fill(0.toChar()) cachedPassword?.fill(0.toChar())
@ -93,15 +91,15 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
private fun getSshKey(make: Boolean) { private fun getSshKey(make: Boolean) {
runCatching { runCatching {
val intent = if (make) { val intent =
if (make) {
Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java) Intent(callingActivity.applicationContext, SshKeyGenActivity::class.java)
} else { } else {
Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java) Intent(callingActivity.applicationContext, SshKeyImportActivity::class.java)
} }
callingActivity.startActivity(intent) callingActivity.startActivity(intent)
}.onFailure { e ->
e(e)
} }
.onFailure { e -> e(e) }
} }
private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) { private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) {
@ -115,17 +113,17 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
} }
} }
/** /** Executes the GitCommand in an async task. */
* Executes the GitCommand in an async task.
*/
suspend fun execute(): Result<Unit, Throwable> { suspend fun execute(): Result<Unit, Throwable> {
if (!preExecute()) { if (!preExecute()) {
return Ok(Unit) return Ok(Unit)
} }
val operationResult = GitCommandExecutor( val operationResult =
GitCommandExecutor(
callingActivity, callingActivity,
this, this,
).execute() )
.execute()
postExecute() postExecute()
return operationResult return operationResult
} }
@ -143,18 +141,20 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
.setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ -> .setNeutralButton(callingActivity.resources.getString(R.string.dialog_cancel)) { _, _ ->
// Finish the blank GitActivity so user doesn't have to press back // Finish the blank GitActivity so user doesn't have to press back
callingActivity.finish() callingActivity.finish()
}.show() }
.show()
} }
suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> { suspend fun executeAfterAuthentication(authMode: AuthMode): Result<Unit, Throwable> {
when (authMode) { when (authMode) {
AuthMode.SshKey -> if (SshKey.exists) { AuthMode.SshKey ->
if (SshKey.exists) {
if (SshKey.mustAuthenticate) { if (SshKey.mustAuthenticate) {
val result = withContext(Dispatchers.Main) { val result =
withContext(Dispatchers.Main) {
suspendCoroutine<BiometricAuthenticator.Result> { cont -> suspendCoroutine<BiometricAuthenticator.Result> { cont ->
BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) { BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
if (it !is BiometricAuthenticator.Result.Failure) if (it !is BiometricAuthenticator.Result.Failure) cont.resume(it)
cont.resume(it)
} }
} }
} }
@ -173,7 +173,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
// their screen lock. Doing so would have a potential to confuse // their screen lock. Doing so would have a potential to confuse
// users though, who might deduce that the screen lock // users though, who might deduce that the screen lock
// protection is not effective. Hence, we fail with an error. // protection is not effective. Hence, we fail with an error.
Toast.makeText(callingActivity.applicationContext, R.string.biometric_auth_generic_failure, Toast.LENGTH_LONG).show() Toast.makeText(
callingActivity.applicationContext,
R.string.biometric_auth_generic_failure,
Toast.LENGTH_LONG
)
.show()
callingActivity.finish() callingActivity.finish()
} }
} }
@ -191,29 +196,21 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password)) val httpsCredentialProvider = HttpsCredentialsProvider(CredentialFinder(callingActivity, AuthMode.Password))
registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider) registerAuthProviders(SshAuthMethod.Password(authActivity), httpsCredentialProvider)
} }
AuthMode.None -> { AuthMode.None -> {}
}
} }
return execute() return execute()
} }
/** /** Called before execution of the Git operation. Return false to cancel. */
* Called before execution of the Git operation.
* Return false to cancel.
*/
open fun preExecute() = true open fun preExecute() = true
private suspend fun postExecute() { private suspend fun postExecute() {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { sshSessionFactory?.close() }
sshSessionFactory?.close()
}
} }
companion object { companion object {
/** /** Timeout in seconds before [TransportCommand] will abort a stalled IO operation. */
* Timeout in seconds before [TransportCommand] will abort a stalled IO operation.
*/
private const val CONNECT_TIMEOUT = 10 private const val CONNECT_TIMEOUT = 10
} }
} }

View file

@ -21,7 +21,8 @@ class PullOperation(
* we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly * we opted to replicate [SyncOperation]'s committing flow within [PullOperation], almost exactly
* replicating [SyncOperation] but leaving the pushing part to [PushOperation]. * replicating [SyncOperation] but leaving the pushing part to [PushOperation].
*/ */
override val commands: Array<GitCommand<out Any>> = arrayOf( override val commands: Array<GitCommand<out Any>> =
arrayOf(
// Stage all files // Stage all files
git.add().addFilepattern("."), git.add().addFilepattern("."),
// Populate the changed files count // Populate the changed files count

View file

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

View file

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

View file

@ -11,7 +11,8 @@ class SyncOperation(
rebase: Boolean, rebase: Boolean,
) : GitOperation(callingActivity) { ) : GitOperation(callingActivity) {
override val commands = arrayOf( override val commands =
arrayOf(
// Stage all files // Stage all files
git.add().addFilepattern("."), git.add().addFilepattern("."),
// Populate the changed files count // Populate the changed files count

View file

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

View file

@ -38,14 +38,16 @@ import org.openintents.ssh.authentication.response.Response
import org.openintents.ssh.authentication.response.SigningResponse import org.openintents.ssh.authentication.response.SigningResponse
import org.openintents.ssh.authentication.response.SshPublicKeyResponse import org.openintents.ssh.authentication.response.SshPublicKeyResponse
class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) : KeyProvider, Closeable { class OpenKeychainKeyProvider private constructor(val activity: ContinuationContainerActivity) :
KeyProvider, Closeable {
companion object { companion object {
suspend fun prepareAndUse(activity: ContinuationContainerActivity, block: (provider: OpenKeychainKeyProvider) -> Unit) { suspend fun prepareAndUse(
withContext(Dispatchers.Main) { activity: ContinuationContainerActivity,
OpenKeychainKeyProvider(activity) block: (provider: OpenKeychainKeyProvider) -> Unit
}.prepareAndUse(block) ) {
withContext(Dispatchers.Main) { OpenKeychainKeyProvider(activity) }.prepareAndUse(block)
} }
} }
@ -63,9 +65,7 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
private var keyId private var keyId
get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null) get() = preferences.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, null)
set(value) { set(value) {
preferences.edit { preferences.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value) }
putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, value)
}
} }
private var publicKey: PublicKey? = null private var publicKey: PublicKey? = null
private var privateKey: OpenKeychainPrivateKey? = null private var privateKey: OpenKeychainPrivateKey? = null
@ -76,8 +76,10 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
} }
private suspend fun prepare() { private suspend fun prepare() {
sshServiceApi = suspendCoroutine { cont -> sshServiceApi =
sshServiceConnection.connect(object : SshAuthenticationConnection.OnBound { suspendCoroutine { cont ->
sshServiceConnection.connect(
object : SshAuthenticationConnection.OnBound {
override fun onBound(sshAgent: ISshAuthenticationService) { override fun onBound(sshAgent: ISshAuthenticationService) {
d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" } d { "Bound to SshAuthenticationApi: $OPENPGP_PROVIDER" }
cont.resume(SshAuthenticationApi(context, sshAgent)) cont.resume(SshAuthenticationApi(context, sshAgent))
@ -86,7 +88,8 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
override fun onError() { override fun onError() {
throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable") throw UserAuthException(DisconnectReason.UNKNOWN, "OpenKeychain service unavailable")
} }
}) }
)
} }
if (keyId == null) { if (keyId == null) {
@ -102,10 +105,11 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
is ApiResponse.Success -> { is ApiResponse.Success -> {
val response = sshPublicKeyResponse.response as SshPublicKeyResponse val response = sshPublicKeyResponse.response as SshPublicKeyResponse
val sshPublicKey = response.sshPublicKey!! val sshPublicKey = response.sshPublicKey!!
publicKey = parseSshPublicKey(sshPublicKey) publicKey =
?: throw IllegalStateException("OpenKeychain API returned invalid SSH key") parseSshPublicKey(sshPublicKey) ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
} }
is ApiResponse.NoSuchKey -> if (isRetry) { is ApiResponse.NoSuchKey ->
if (isRetry) {
throw sshPublicKeyResponse.exception throw sshPublicKeyResponse.exception
} else { } else {
// Allow the user to reselect an authentication key and retry // Allow the user to reselect an authentication key and retry
@ -126,29 +130,32 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse { private suspend fun executeApiRequest(request: Request, resultOfUserInteraction: Intent? = null): ApiResponse {
d { "executeRequest($request) called" } d { "executeRequest($request) called" }
val result = withContext(Dispatchers.Main) { val result =
// If the request required user interaction, the data returned from the PendingIntent withContext(Dispatchers.Main) {
// If the request required user interaction, the data returned from the
// PendingIntent
// is used as the real request. // is used as the real request.
sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!! sshServiceApi.executeApi(resultOfUserInteraction ?: request.toIntent())!!
} }
return parseResult(request, result).also { return parseResult(request, result).also { d { "executeRequest($request): $it" } }
d { "executeRequest($request): $it" }
}
} }
private suspend fun parseResult(request: Request, result: Intent): ApiResponse { private suspend fun parseResult(request: Request, result: Intent): ApiResponse {
return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) { return when (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, SshAuthenticationApi.RESULT_CODE_ERROR)) {
SshAuthenticationApi.RESULT_CODE_SUCCESS -> { SshAuthenticationApi.RESULT_CODE_SUCCESS -> {
ApiResponse.Success(when (request) { ApiResponse.Success(
when (request) {
is KeySelectionRequest -> KeySelectionResponse(result) is KeySelectionRequest -> KeySelectionResponse(result)
is SshPublicKeyRequest -> SshPublicKeyResponse(result) is SshPublicKeyRequest -> SshPublicKeyResponse(result)
is SigningRequest -> SigningResponse(result) is SigningRequest -> SigningResponse(result)
else -> throw IllegalArgumentException("Unsupported OpenKeychain request type") else -> throw IllegalArgumentException("Unsupported OpenKeychain request type")
}) }
)
} }
SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> { SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!! val pendingIntent: PendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT)!!
val resultOfUserInteraction: Intent = withContext(Dispatchers.Main) { val resultOfUserInteraction: Intent =
withContext(Dispatchers.Main) {
suspendCoroutine { cont -> suspendCoroutine { cont ->
activity.stashedCont = cont activity.stashedCont = cont
activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build()) activity.continueAfterUserInteraction.launch(IntentSenderRequest.Builder(pendingIntent).build())
@ -158,9 +165,11 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
} }
else -> { else -> {
val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR) val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
val exception = UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}") val exception =
UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}")
when (error?.error) { when (error?.error) {
SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY -> ApiResponse.NoSuchKey(exception) SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY ->
ApiResponse.NoSuchKey(exception)
else -> ApiResponse.GeneralError(exception) else -> ApiResponse.GeneralError(exception)
} }
} }
@ -169,7 +178,8 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
private fun makePrivateKey() { private fun makePrivateKey() {
check(keyId != null && publicKey != null) check(keyId != null && publicKey != null)
privateKey = object : OpenKeychainPrivateKey { privateKey =
object : OpenKeychainPrivateKey {
override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) = override suspend fun sign(challenge: ByteArray, hashAlgorithm: Int) =
when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) { when (val signingResponse = executeApiRequest(SigningRequest(challenge, keyId, hashAlgorithm))) {
is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature is ApiResponse.Success -> (signingResponse.response as SigningResponse).signature
@ -184,9 +194,7 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
override fun close() { override fun close() {
activity.lifecycleScope.launch { activity.lifecycleScope.launch {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) { activity.continueAfterUserInteraction.unregister() }
activity.continueAfterUserInteraction.unregister()
}
} }
sshServiceConnection.disconnect() sshServiceConnection.disconnect()
} }

View file

@ -8,8 +8,6 @@ import com.hierynomus.sshj.key.KeyAlgorithm
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.security.PrivateKey import java.security.PrivateKey
import java.security.interfaces.ECKey import java.security.interfaces.ECKey
import java.security.interfaces.ECPrivateKey
import java.security.spec.ECParameterSpec
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import net.schmizz.sshj.common.Buffer import net.schmizz.sshj.common.Buffer
import net.schmizz.sshj.common.Factory import net.schmizz.sshj.common.Factory
@ -24,14 +22,16 @@ interface OpenKeychainPrivateKey : PrivateKey, ECKey {
override fun getEncoded() = null override fun getEncoded() = null
} }
class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) : Factory.Named<KeyAlgorithm> by factory { class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<KeyAlgorithm>) :
Factory.Named<KeyAlgorithm> by factory {
override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create()) override fun create() = OpenKeychainWrappedKeyAlgorithm(factory.create())
} }
class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm { class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) : KeyAlgorithm by keyAlgorithm {
private val hashAlgorithm = when (keyAlgorithm.keyAlgorithm) { private val hashAlgorithm =
when (keyAlgorithm.keyAlgorithm) {
"rsa-sha2-512" -> SshAuthenticationApi.SHA512 "rsa-sha2-512" -> SshAuthenticationApi.SHA512
"rsa-sha2-256" -> SshAuthenticationApi.SHA256 "rsa-sha2-256" -> SshAuthenticationApi.SHA256
"ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1 "ssh-rsa", "ssh-rsa-cert-v01@openssh.com" -> SshAuthenticationApi.SHA1
@ -42,7 +42,8 @@ class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) :
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()
@ -72,18 +73,19 @@ class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, priv
} }
} }
override fun sign(): ByteArray? = if (bridgedPrivateKey != null) { override fun sign(): ByteArray? =
runBlocking { if (bridgedPrivateKey != null) {
bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) runBlocking { bridgedPrivateKey!!.sign(data.toByteArray(), hashAlgorithm) }
}
} else { } else {
wrappedSignature.sign() wrappedSignature.sign()
} }
override fun encode(signature: ByteArray?): ByteArray? = if (bridgedPrivateKey != null) { override fun encode(signature: ByteArray?): ByteArray? =
if (bridgedPrivateKey != null) {
require(signature != null) { "OpenKeychain signature must not be null" } require(signature != null) { "OpenKeychain signature must not be null" }
val encodedSignature = Buffer.PlainBuffer(signature) val encodedSignature = Buffer.PlainBuffer(signature)
// We need to drop the algorithm name and extract the raw signature since SSHJ adds the name // We need to drop the algorithm name and extract the raw signature since SSHJ adds the
// name
// later. // later.
encodedSignature.readString() encodedSignature.readString()
encodedSignature.readBytes().also { encodedSignature.readBytes().also {

View file

@ -62,8 +62,7 @@ private val KeyStore.sshPublicKey
fun parseSshPublicKey(sshPublicKey: String): PublicKey? { fun parseSshPublicKey(sshPublicKey: String): PublicKey? {
val sshKeyParts = sshPublicKey.split("""\s+""".toRegex()) val sshKeyParts = sshPublicKey.split("""\s+""".toRegex())
if (sshKeyParts.size < 2) if (sshKeyParts.size < 2) return null
return null
return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey() return Buffer.PlainBuffer(Base64.decode(sshKeyParts[1], Base64.NO_WRAP)).readPublicKey()
} }
@ -84,8 +83,7 @@ object SshKey {
val mustAuthenticate: Boolean val mustAuthenticate: Boolean
get() { get() {
return runCatching { return runCatching {
if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) return false
return false
when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) { when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) {
is PrivateKey -> { is PrivateKey -> {
val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) val factory = KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE)
@ -97,8 +95,10 @@ object SshKey {
} }
else -> throw IllegalStateException("SSH key does not exist in Keystore") else -> throw IllegalStateException("SSH key does not exist in Keystore")
} }
}.getOrElse { error -> }
// It is fine to swallow the exception here since it will reappear when the key is .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. // used for SSH authentication and can then be shown in the UI.
d(error) d(error)
false false
@ -115,15 +115,12 @@ object SshKey {
private var type: Type? private var type: Type?
get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE)) get() = Type.fromValue(context.sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_KEY_TYPE))
set(value) = context.sharedPrefs.edit { set(value) = context.sharedPrefs.edit { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) }
putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value)
}
private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) { private val isStrongBoxSupported by lazy(LazyThreadSafetyMode.NONE) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE)
else else false
false
} }
private enum class Type(val value: String) { private enum class Type(val value: String) {
@ -142,53 +139,57 @@ object SshKey {
} }
enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) { enum class Algorithm(val algorithm: String, val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit) {
Rsa(KeyProperties.KEY_ALGORITHM_RSA, { Rsa(
KeyProperties.KEY_ALGORITHM_RSA,
{
setKeySize(3072) setKeySize(3072)
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
}), }
Ecdsa(KeyProperties.KEY_ALGORITHM_EC, { ),
Ecdsa(
KeyProperties.KEY_ALGORITHM_EC,
{
setKeySize(256) setKeySize(256)
setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
setDigests(KeyProperties.DIGEST_SHA256) setDigests(KeyProperties.DIGEST_SHA256)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setIsStrongBoxBacked(isStrongBoxSupported) setIsStrongBoxBacked(isStrongBoxSupported)
} }
}), }
),
} }
private fun delete() { private fun delete() {
androidKeystore.deleteEntry(KEYSTORE_ALIAS) androidKeystore.deleteEntry(KEYSTORE_ALIAS)
// Remove Tink key set used by AndroidX's EncryptedFile. // Remove Tink key set used by AndroidX's EncryptedFile.
context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit { clear() }
clear()
}
if (privateKeyFile.isFile) { if (privateKeyFile.isFile) {
privateKeyFile.delete() privateKeyFile.delete()
} }
if (publicKeyFile.isFile) { if (publicKeyFile.isFile) {
publicKeyFile.delete() publicKeyFile.delete()
} }
context.getEncryptedGitPrefs().edit { context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) }
remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE)
}
type = null type = null
} }
fun import(uri: Uri) { fun import(uri: Uri) {
// First check whether the content at uri is likely an SSH private key. // 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) val fileSize =
?.use { cursor -> context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
// Cursor returns only a single row. // Cursor returns only a single row.
cursor.moveToFirst() cursor.moveToFirst()
cursor.getInt(0) cursor.getInt(0)
} ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) }
?: 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. // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes.
if (fileSize > 100_000 || fileSize == 0) if (fileSize > 100_000 || fileSize == 0)
throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message)) throw IllegalArgumentException(context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message))
val sshKeyInputStream = context.contentResolver.openInputStream(uri) val sshKeyInputStream =
context.contentResolver.openInputStream(uri)
?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist))
val lines = sshKeyInputStream.bufferedReader().readLines() val lines = sshKeyInputStream.bufferedReader().readLines()
@ -215,7 +216,8 @@ object SshKey {
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) =
withContext(Dispatchers.IO) {
MasterKey.Builder(context, KEYSTORE_ALIAS).run { MasterKey.Builder(context, KEYSTORE_ALIAS).run {
setKeyScheme(MasterKey.KeyScheme.AES256_GCM) setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
setRequestStrongBoxBacked(true) setRequestStrongBoxBacked(true)
@ -225,26 +227,29 @@ object SshKey {
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) =
EncryptedFile.Builder(context, withContext(Dispatchers.IO) {
EncryptedFile.Builder(
context,
privateKeyFile, privateKeyFile,
getOrCreateWrappingMasterKey(requireAuthentication), getOrCreateWrappingMasterKey(requireAuthentication),
EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB).run { EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
)
.run {
setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME) setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME)
build() build()
} }
} }
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = withContext(Dispatchers.IO) { suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) =
withContext(Dispatchers.IO) {
delete() delete()
val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication) val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication)
// Generate the ed25519 key pair and encrypt the private key. // Generate the ed25519 key pair and encrypt the private key.
val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair()
encryptedPrivateKeyFile.openFileOutput().use { os -> encryptedPrivateKeyFile.openFileOutput().use { os -> os.write((keyPair.private as EdDSAPrivateKey).seed) }
os.write((keyPair.private as EdDSAPrivateKey).seed)
}
// Write public key in SSH format to .ssh_key.pub. // Write public key in SSH format to .ssh_key.pub.
publicKeyFile.writeText(toSshPublicKey(keyPair.public)) publicKeyFile.writeText(toSshPublicKey(keyPair.public))
@ -256,22 +261,21 @@ object SshKey {
delete() delete()
// Generate Keystore-backed private key. // Generate Keystore-backed private key.
val parameterSpec = KeyGenParameterSpec.Builder( val parameterSpec =
KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run {
).run {
apply(algorithm.applyToSpec) apply(algorithm.applyToSpec)
if (requireAuthentication) { if (requireAuthentication) {
setUserAuthenticationRequired(true) setUserAuthenticationRequired(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL) setUserAuthenticationParameters(30, KeyProperties.AUTH_DEVICE_CREDENTIAL)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds(30)
setUserAuthenticationValidityDurationSeconds(30)
} }
} }
build() build()
} }
val keyPair = KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run { val keyPair =
KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run {
initialize(parameterSpec) initialize(parameterSpec)
generateKeyPair() generateKeyPair()
} }
@ -282,7 +286,8 @@ object SshKey {
type = Type.KeystoreNative type = Type.KeystoreNative
} }
fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? = when (type) { fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? =
when (type) {
Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder) Type.LegacyGenerated, Type.Imported -> client.loadKeys(privateKeyFile.absolutePath, passphraseFinder)
Type.KeystoreNative -> KeystoreNativeKeyProvider Type.KeystoreNative -> KeystoreNativeKeyProvider
Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider Type.KeystoreWrappedEd25519 -> KeystoreWrappedEd25519KeyProvider
@ -291,16 +296,14 @@ object SshKey {
private object KeystoreNativeKeyProvider : KeyProvider { private object KeystoreNativeKeyProvider : KeyProvider {
override fun getPublic(): PublicKey = runCatching { override fun getPublic(): PublicKey =
androidKeystore.sshPublicKey!! runCatching { androidKeystore.sshPublicKey!! }.getOrElse { error ->
}.getOrElse { error ->
e(error) e(error)
throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error) throw IOException("Failed to get public key '$KEYSTORE_ALIAS' from Android Keystore", error)
} }
override fun getPrivate(): PrivateKey = runCatching { override fun getPrivate(): PrivateKey =
androidKeystore.sshPrivateKey!! runCatching { androidKeystore.sshPrivateKey!! }.getOrElse { error ->
}.getOrElse { error ->
e(error) e(error)
throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error) throw IOException("Failed to access private key '$KEYSTORE_ALIAS' from Android Keystore", error)
} }
@ -310,23 +313,22 @@ object SshKey {
private object KeystoreWrappedEd25519KeyProvider : KeyProvider { private object KeystoreWrappedEd25519KeyProvider : KeyProvider {
override fun getPublic(): PublicKey = runCatching { override fun getPublic(): PublicKey =
parseSshPublicKey(sshPublicKey!!)!! runCatching { parseSshPublicKey(sshPublicKey!!)!! }.getOrElse { error ->
}.getOrElse { error ->
e(error) e(error)
throw IOException("Failed to get the public key for wrapped ed25519 key", error) throw IOException("Failed to get the public key for wrapped ed25519 key", error)
} }
override fun getPrivate(): PrivateKey = runCatching { override fun getPrivate(): PrivateKey =
runCatching {
// The current MasterKey API does not allow getting a reference to an existing one // 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 // 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. // for `requireAuthentication` is not used as the key already exists at this point.
val encryptedPrivateKeyFile = runBlocking { val encryptedPrivateKeyFile = runBlocking { getOrCreateWrappedPrivateKeyFile(false) }
getOrCreateWrappedPrivateKeyFile(false)
}
val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() } val rawPrivateKey = encryptedPrivateKeyFile.openFileInput().use { it.readBytes() }
EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC)) EdDSAPrivateKey(EdDSAPrivateKeySpec(rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC))
}.getOrElse { error -> }
.getOrElse { error ->
e(error) e(error)
throw IOException("Failed to unwrap wrapped ed25519 key", error) throw IOException("Failed to unwrap wrapped ed25519 key", error)
} }

View file

@ -33,14 +33,11 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.Marker import org.slf4j.Marker
fun setUpBouncyCastleForSshj() { fun setUpBouncyCastleForSshj() {
// Replace the Android BC provider with the Java BouncyCastle provider since the former does // Replace the Android BC provider with the Java BouncyCastle provider since the former does
// not include all the required algorithms. // not include all the required algorithms.
// Note: This may affect crypto operations in other parts of the application. // Note: This may affect crypto operations in other parts of the application.
val bcIndex = Security.getProviders().indexOfFirst { val bcIndex = Security.getProviders().indexOfFirst { it.name == BouncyCastleProvider.PROVIDER_NAME }
it.name == BouncyCastleProvider.PROVIDER_NAME
}
if (bcIndex == -1) { if (bcIndex == -1) {
// No Android BC found, install Java BC at lowest priority. // No Android BC found, install Java BC at lowest priority.
Security.addProvider(BouncyCastleProvider()) Security.addProvider(BouncyCastleProvider())
@ -80,11 +77,9 @@ private abstract class AbstractLogger(private val name: String) : Logger {
override fun trace(msg: String, t: Throwable?) = t(msg, t) override fun trace(msg: String, t: Throwable?) = t(msg, t)
override fun trace(marker: Marker, msg: String) = trace(msg) override fun trace(marker: Marker, msg: String) = trace(msg)
override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg) override fun trace(marker: Marker?, format: String, arg: Any?) = trace(format, arg)
override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = trace(format, arg1, arg2)
trace(format, arg1, arg2)
override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = trace(format, *arguments)
trace(format, *arguments)
override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t) override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t)
@ -95,11 +90,9 @@ private abstract class AbstractLogger(private val name: String) : Logger {
override fun debug(msg: String, t: Throwable?) = d(msg, t) override fun debug(msg: String, t: Throwable?) = d(msg, t)
override fun debug(marker: Marker, msg: String) = debug(msg) override fun debug(marker: Marker, msg: String) = debug(msg)
override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg) override fun debug(marker: Marker?, format: String, arg: Any?) = debug(format, arg)
override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = debug(format, arg1, arg2)
debug(format, arg1, arg2)
override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = debug(format, *arguments)
debug(format, *arguments)
override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t) override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t)
@ -110,11 +103,9 @@ private abstract class AbstractLogger(private val name: String) : Logger {
override fun info(msg: String, t: Throwable?) = i(msg, t) override fun info(msg: String, t: Throwable?) = i(msg, t)
override fun info(marker: Marker, msg: String) = info(msg) override fun info(marker: Marker, msg: String) = info(msg)
override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg) override fun info(marker: Marker?, format: String, arg: Any?) = info(format, arg)
override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = info(format, arg1, arg2)
info(format, arg1, arg2)
override fun info(marker: Marker?, format: String, vararg arguments: Any?) = override fun info(marker: Marker?, format: String, vararg arguments: Any?) = info(format, *arguments)
info(format, *arguments)
override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t) override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t)
@ -125,11 +116,9 @@ private abstract class AbstractLogger(private val name: String) : Logger {
override fun warn(msg: String, t: Throwable?) = w(msg, t) override fun warn(msg: String, t: Throwable?) = w(msg, t)
override fun warn(marker: Marker, msg: String) = warn(msg) override fun warn(marker: Marker, msg: String) = warn(msg)
override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg) override fun warn(marker: Marker?, format: String, arg: Any?) = warn(format, arg)
override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = warn(format, arg1, arg2)
warn(format, arg1, arg2)
override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = warn(format, *arguments)
warn(format, *arguments)
override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t) override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t)
@ -140,11 +129,9 @@ private abstract class AbstractLogger(private val name: String) : Logger {
override fun error(msg: String, t: Throwable?) = e(msg, t) override fun error(msg: String, t: Throwable?) = e(msg, t)
override fun error(marker: Marker, msg: String) = error(msg) override fun error(marker: Marker, msg: String) = error(msg)
override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg) override fun error(marker: Marker?, format: String, arg: Any?) = error(format, arg)
override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) = error(format, arg1, arg2)
error(format, arg1, arg2)
override fun error(marker: Marker?, format: String, vararg arguments: Any?) = override fun error(marker: Marker?, format: String, vararg arguments: Any?) = error(format, *arguments)
error(format, *arguments)
override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t) override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t)
} }
@ -161,8 +148,7 @@ object TimberLoggerFactory : LoggerFactory {
// Replace slf4j's "{}" format string style with standard Java's "%s". // Replace slf4j's "{}" format string style with standard Java's "%s".
// The supposedly redundant escape on the } is not redundant. // The supposedly redundant escape on the } is not redundant.
@Suppress("RegExpRedundantEscape") @Suppress("RegExpRedundantEscape") private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
override fun t(message: String, t: Throwable?, vararg args: Any?) { override fun t(message: String, t: Throwable?, vararg args: Any?) {
Timber.tag(name).v(t, message.fix(), *args) Timber.tag(name).v(t, message.fix(), *args)
@ -192,7 +178,6 @@ object TimberLoggerFactory : LoggerFactory {
override fun getLogger(clazz: Class<*>): Logger { override fun getLogger(clazz: Class<*>): Logger {
return TimberLogger(clazz.name) return TimberLogger(clazz.name)
} }
} }
class SshjConfig : ConfigImpl() { class SshjConfig : ConfigImpl() {
@ -212,21 +197,24 @@ class SshjConfig : ConfigImpl() {
} }
private fun initKeyExchangeFactories() { private fun initKeyExchangeFactories() {
keyExchangeFactories = listOf( keyExchangeFactories =
listOf(
Curve25519SHA256.Factory(), Curve25519SHA256.Factory(),
FactoryLibSsh(), FactoryLibSsh(),
ECDHNistP.Factory521(), ECDHNistP.Factory521(),
ECDHNistP.Factory384(), ECDHNistP.Factory384(),
ECDHNistP.Factory256(), ECDHNistP.Factory256(),
DHGexSHA256.Factory(), DHGexSHA256.Factory(),
// Sends "ext-info-c" with the list of key exchange algorithms. This is needed to get // 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). // rsa-sha2-* key types to work with some servers (e.g. GitHub).
ExtInfoClientFactory(), ExtInfoClientFactory(),
) )
} }
private fun initKeyAlgorithms() { private fun initKeyAlgorithms() {
keyAlgorithms = listOf( keyAlgorithms =
listOf(
KeyAlgorithms.SSHRSACertV01(), KeyAlgorithms.SSHRSACertV01(),
KeyAlgorithms.EdDSA25519(), KeyAlgorithms.EdDSA25519(),
KeyAlgorithms.ECDSASHANistp521(), KeyAlgorithms.ECDSASHANistp521(),
@ -235,9 +223,8 @@ class SshjConfig : ConfigImpl() {
KeyAlgorithms.RSASHA512(), KeyAlgorithms.RSASHA512(),
KeyAlgorithms.RSASHA256(), KeyAlgorithms.RSASHA256(),
KeyAlgorithms.SSHRSA(), KeyAlgorithms.SSHRSA(),
).map { )
OpenKeychainWrappedKeyAlgorithmFactory(it) .map { OpenKeychainWrappedKeyAlgorithmFactory(it) }
}
} }
private fun initRandomFactory() { private fun initRandomFactory() {
@ -245,7 +232,8 @@ class SshjConfig : ConfigImpl() {
} }
private fun initFileKeyProviderFactories() { private fun initFileKeyProviderFactories() {
fileKeyProviderFactories = listOf( fileKeyProviderFactories =
listOf(
OpenSSHKeyV1KeyFile.Factory(), OpenSSHKeyV1KeyFile.Factory(),
PKCS8KeyFile.Factory(), PKCS8KeyFile.Factory(),
PKCS5KeyFile.Factory(), PKCS5KeyFile.Factory(),
@ -254,9 +242,9 @@ class SshjConfig : ConfigImpl() {
) )
} }
private fun initCipherFactories() { private fun initCipherFactories() {
cipherFactories = listOf( cipherFactories =
listOf(
GcmCiphers.AES128GCM(), GcmCiphers.AES128GCM(),
GcmCiphers.AES256GCM(), GcmCiphers.AES256GCM(),
BlockCiphers.AES256CTR(), BlockCiphers.AES256CTR(),
@ -266,7 +254,8 @@ class SshjConfig : ConfigImpl() {
} }
private fun initMACFactories() { private fun initMACFactories() {
macFactories = listOf( macFactories =
listOf(
Macs.HMACSHA2512Etm(), Macs.HMACSHA2512Etm(),
Macs.HMACSHA2256Etm(), Macs.HMACSHA2256Etm(),
Macs.HMACSHA2512(), Macs.HMACSHA2512(),
@ -275,7 +264,8 @@ class SshjConfig : ConfigImpl() {
} }
private fun initCompressionFactories() { private fun initCompressionFactories() {
compressionFactories = listOf( compressionFactories =
listOf(
NoneCompression.Factory(), NoneCompression.Factory(),
) )
} }

View file

@ -52,14 +52,9 @@ abstract class InteractivePasswordFinder : PasswordFinder {
abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean) abstract fun askForPassword(cont: Continuation<String?>, isRetry: Boolean)
final override fun reqPassword(resource: Resource<*>?): CharArray { final override fun reqPassword(resource: Resource<*>?): CharArray {
val password = runBlocking(Dispatchers.Main) { val password = runBlocking(Dispatchers.Main) { suspendCoroutine<String?> { cont -> askForPassword(cont, isRetry) } }
suspendCoroutine<String?> { cont ->
askForPassword(cont, isRetry)
}
}
isRetry = true isRetry = true
return password?.toCharArray() return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
} }
final override fun shouldRetry(resource: Resource<*>?) = true final override fun shouldRetry(resource: Resource<*>?) = true
@ -85,11 +80,8 @@ class SshjSessionFactory(private val authMethod: SshAuthMethod, private val host
private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier { private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
if (!hostKeyFile.exists()) { if (!hostKeyFile.exists()) {
return HostKeyVerifier { _, _, key -> return HostKeyVerifier { _, _, key ->
val digest = runCatching { val digest =
SecurityUtils.getMessageDigest("SHA-256") runCatching { SecurityUtils.getMessageDigest("SHA-256") }.getOrElse { e -> throw SSHRuntimeException(e) }
}.getOrElse { e ->
throw SSHRuntimeException(e)
}
digest.update(PlainBuffer().putPublicKey(key).compactData) digest.update(PlainBuffer().putPublicKey(key).compactData)
val digestData = digest.digest() val digestData = digest.digest()
val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}" val hostKeyEntry = "SHA256:${Base64.encodeToString(digestData, Base64.NO_WRAP)}"
@ -104,12 +96,18 @@ private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
} }
} }
private class SshjSession(uri: URIish, private val username: String, private val authMethod: SshAuthMethod, private val hostKeyFile: File) : RemoteSession { private class SshjSession(
uri: URIish,
private val username: String,
private val authMethod: SshAuthMethod,
private val hostKeyFile: File
) : RemoteSession {
private lateinit var ssh: SSHClient private lateinit var ssh: SSHClient
private var currentCommand: Session? = null private var currentCommand: Session? = null
private val uri = if (uri.host.contains('@')) { private val uri =
if (uri.host.contains('@')) {
// URIish's String constructor cannot handle '@' in the user part of the URI and the URL // 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 // constructor can't be used since Java's URL does not recognize the ssh scheme. We thus
// need to patch everything up ourselves. // need to patch everything up ourselves.
@ -126,8 +124,7 @@ private class SshjSession(uri: URIish, private val username: String, private val
ssh = SSHClient(SshjConfig()) ssh = SSHClient(SshjConfig())
ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile)) ssh.addHostKeyVerifier(makeTofuHostKeyVerifier(hostKeyFile))
ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22) ssh.connect(uri.host, uri.port.takeUnless { it == -1 } ?: 22)
if (!ssh.isConnected) if (!ssh.isConnected) throw IOException()
throw IOException()
val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password)) val passwordAuth = AuthPassword(CredentialFinder(authMethod.activity, AuthMode.Password))
when (authMethod) { when (authMethod) {
is SshAuthMethod.Password -> { is SshAuthMethod.Password -> {
@ -163,8 +160,8 @@ private class SshjSession(uri: URIish, private val username: String, private val
* Kills the current command if one is running and returns the session into a state where `exec` * Kills the current command if one is running and returns the session into a state where `exec`
* can be called. * can be called.
* *
* Note that this does *not* disconnect the session. Unfortunately, the function has to be * Note that this does *not* disconnect the session. Unfortunately, the function has to be called
* called `disconnect` to override the corresponding abstract function in `RemoteSession`. * `disconnect` to override the corresponding abstract function in `RemoteSession`.
*/ */
override fun disconnect() { override fun disconnect() {
currentCommand?.close() currentCommand?.close()

View file

@ -15,19 +15,16 @@ import java.net.ProxySelector
import java.net.SocketAddress import java.net.SocketAddress
import java.net.URI import java.net.URI
/** /** Utility class for [Proxy] handling. */
* Utility class for [Proxy] handling.
*/
object ProxyUtils { object ProxyUtils {
private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser" private const val HTTP_PROXY_USER_PROPERTY = "http.proxyUser"
private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword" private const val HTTP_PROXY_PASSWORD_PROPERTY = "http.proxyPassword"
/** /** Set the default [Proxy] and [Authenticator] for the app based on user provided settings. */
* Set the default [Proxy] and [Authenticator] for the app based on user provided settings.
*/
fun setDefaultProxy() { fun setDefaultProxy() {
ProxySelector.setDefault(object : ProxySelector() { ProxySelector.setDefault(
object : ProxySelector() {
override fun select(uri: URI?): MutableList<Proxy> { override fun select(uri: URI?): MutableList<Proxy> {
val host = GitSettings.proxyHost val host = GitSettings.proxyHost
val port = GitSettings.proxyPort val port = GitSettings.proxyPort
@ -43,7 +40,8 @@ object ProxyUtils {
throw IllegalArgumentException("Arguments can't be null.") throw IllegalArgumentException("Arguments can't be null.")
} }
} }
}) }
)
val user = GitSettings.proxyUsername ?: "" val user = GitSettings.proxyUsername ?: ""
val password = GitSettings.proxyPassword ?: "" val password = GitSettings.proxyPassword ?: ""
if (user.isEmpty() || password.isEmpty()) { if (user.isEmpty() || password.isEmpty()) {
@ -53,7 +51,8 @@ object ProxyUtils {
System.setProperty(HTTP_PROXY_USER_PROPERTY, user) System.setProperty(HTTP_PROXY_USER_PROPERTY, user)
System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password) System.setProperty(HTTP_PROXY_PASSWORD_PROPERTY, password)
} }
Authenticator.setDefault(object : Authenticator() { Authenticator.setDefault(
object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? { override fun getPasswordAuthentication(): PasswordAuthentication? {
return if (requestorType == RequestorType.PROXY) { return if (requestorType == RequestorType.PROXY) {
PasswordAuthentication(user, password.toCharArray()) PasswordAuthentication(user, password.toCharArray())
@ -61,6 +60,7 @@ object ProxyUtils {
null null
} }
} }
}) }
)
} }
} }

View file

@ -37,35 +37,27 @@ object PasswordGenerator {
const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2" const val AMBIGUOUS_STR = "B8G6I1l0OQDS5Z2"
/** /**
* Enables the [PasswordOption]s in [options] and sets [targetLength] as the length for * Enables the [PasswordOption] s in [options] and sets [targetLength] as the length for generated
* generated passwords. * passwords.
*/ */
fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean { fun setPrefs(ctx: Context, options: List<PasswordOption>, targetLength: Int): Boolean {
ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit { ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE).edit {
for (possibleOption in PasswordOption.values()) for (possibleOption in PasswordOption.values()) putBoolean(possibleOption.key, possibleOption in options)
putBoolean(possibleOption.key, possibleOption in options)
putInt("length", targetLength) putInt("length", targetLength)
} }
return true return true
} }
fun isValidPassword(password: String, pwFlags: Int): Boolean { fun isValidPassword(password: String, pwFlags: Int): Boolean {
if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) if (pwFlags hasFlag DIGITS && password.none { it in DIGITS_STR }) return false
return false if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) return false
if (pwFlags hasFlag UPPERS && password.none { it in UPPERS_STR }) if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) return false
return false if (pwFlags hasFlag SYMBOLS && password.none { it in SYMBOLS_STR }) return false
if (pwFlags hasFlag LOWERS && password.none { it in LOWERS_STR }) if (pwFlags hasFlag NO_AMBIGUOUS && password.any { it in AMBIGUOUS_STR }) return false
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 return true
} }
/** /** Generates a password using the preferences set by [setPrefs]. */
* Generates a password using the preferences set by [setPrefs].
*/
@Throws(PasswordGeneratorException::class) @Throws(PasswordGeneratorException::class)
fun generate(ctx: Context): String { fun generate(ctx: Context): String {
val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE) val prefs = ctx.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
@ -90,16 +82,13 @@ object PasswordGenerator {
} else { } else {
// The No* options are false, so the respective character category will be included. // The No* options are false, so the respective character category will be included.
when (option) { when (option) {
PasswordOption.NoDigits, PasswordOption.NoDigits, PasswordOption.NoUppercaseLetters, PasswordOption.NoLowercaseLetters -> {
PasswordOption.NoUppercaseLetters,
PasswordOption.NoLowercaseLetters -> {
numCharacterCategories++ numCharacterCategories++
} }
PasswordOption.NoAmbiguousCharacters, PasswordOption.NoAmbiguousCharacters,
PasswordOption.FullyRandom, PasswordOption.FullyRandom,
// Since AtLeastOneSymbol is not negated, it is counted in the if branch. // Since AtLeastOneSymbol is not negated, it is counted in the if branch.
PasswordOption.AtLeastOneSymbol -> { PasswordOption.AtLeastOneSymbol -> {}
}
} }
} }
} }
@ -126,7 +115,8 @@ object PasswordGenerator {
do { do {
if (iterations++ > 1000) if (iterations++ > 1000)
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded)) throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_max_iterations_exceeded))
password = if (phonemes) { password =
if (phonemes) {
RandomPhonemesGenerator.generate(length, pwgenFlags) RandomPhonemesGenerator.generate(length, pwgenFlags)
} else { } else {
RandomPasswordGenerator.generate(length, pwgenFlags) RandomPasswordGenerator.generate(length, pwgenFlags)

View file

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

View file

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

View file

@ -14,7 +14,8 @@ object RandomPhonemesGenerator {
private const val DIPHTHONG = 0x0004 private const val DIPHTHONG = 0x0004
private const val NOT_FIRST = 0x0008 private const val NOT_FIRST = 0x0008
private val elements = arrayOf( private val elements =
arrayOf(
Element("a", VOWEL), Element("a", VOWEL),
Element("ae", VOWEL or DIPHTHONG), Element("ae", VOWEL or DIPHTHONG),
Element("ah", VOWEL or DIPHTHONG), Element("ah", VOWEL or DIPHTHONG),
@ -66,16 +67,16 @@ object RandomPhonemesGenerator {
} }
/** /**
* Generates a random human-readable password of length [targetLength], taking the following * Generates a random human-readable password of length [targetLength], taking the following flags
* flags in [pwFlags] into account, or fails to do so and returns null: * in [pwFlags] into account, or fails to do so and returns null:
* *
* - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not * - [PasswordGenerator.DIGITS]: If set, the password will contain at least one digit; if not set,
* set, the password will not contain any digits. * the password will not contain any digits.
* - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase * - [PasswordGenerator.UPPERS]: If set, the password will contain at least one uppercase letter;
* letter; if not set, the password will not contain any uppercase letters. * if not set, the password will not contain any uppercase letters.
* - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase * - [PasswordGenerator.LOWERS]: If set, the password will contain at least one lowercase letter;
* letter; if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any * if not set and [PasswordGenerator.UPPERS] is set, the password will not contain any lowercase
* lowercase characters; if both are not set, an exception is thrown. * characters; if both are not set, an exception is thrown.
* - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not * - [PasswordGenerator.SYMBOLS]: If set, the password will contain at least one symbol; if not
* set, the password will not contain any symbols. * set, the password will not contain any symbols.
* - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous * - [PasswordGenerator.NO_AMBIGUOUS]: If set, the password will not contain any ambiguous
@ -102,7 +103,8 @@ object RandomPhonemesGenerator {
(previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) || (previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) ||
// Don't add multi-character candidates if we would go over the targetLength. // Don't add multi-character candidates if we would go over the targetLength.
(password.length + candidate.length > targetLength) || (password.length + candidate.length > targetLength) ||
(pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)) { (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)
) {
continue continue
} }
@ -111,8 +113,10 @@ object RandomPhonemesGenerator {
// any. // any.
val useUpperIfBothCasesAllowed = val useUpperIfBothCasesAllowed =
(isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20) (isStartOfPart || candidate.flags hasFlag CONSONANT) && secureRandomBiasedBoolean(20)
password += if (pwFlags hasFlag PasswordGenerator.UPPERS && password +=
(!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)) { if (pwFlags hasFlag PasswordGenerator.UPPERS &&
(!(pwFlags hasFlag PasswordGenerator.LOWERS) || useUpperIfBothCasesAllowed)
) {
candidate.upperCase candidate.upperCase
} else { } else {
candidate.lowerCase candidate.lowerCase
@ -120,19 +124,16 @@ object RandomPhonemesGenerator {
// We ensured above that we will not go above the target length. // We ensured above that we will not go above the target length.
check(password.length <= targetLength) check(password.length <= targetLength)
if (password.length == targetLength) if (password.length == targetLength) break
break
// Second part: Add digits and symbols with a certain probability (if requested) if // 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. // they would not directly follow the first character in a pronounceable part.
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS && if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.DIGITS && secureRandomBiasedBoolean(30)) {
secureRandomBiasedBoolean(30)) {
var randomDigit: Char var randomDigit: Char
do { do {
randomDigit = secureRandomNumber(10).toString(10).first() randomDigit = secureRandomNumber(10).toString(10).first()
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && randomDigit in PasswordGenerator.AMBIGUOUS_STR)
randomDigit in PasswordGenerator.AMBIGUOUS_STR)
password += randomDigit password += randomDigit
// Begin a new pronounceable part after every digit. // Begin a new pronounceable part after every digit.
@ -142,23 +143,22 @@ object RandomPhonemesGenerator {
continue continue
} }
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS && if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS && secureRandomBiasedBoolean(20)) {
secureRandomBiasedBoolean(20)) {
var randomSymbol: Char var randomSymbol: Char
do { do {
randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter() randomSymbol = PasswordGenerator.SYMBOLS_STR.secureRandomCharacter()
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && } while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && randomSymbol in PasswordGenerator.AMBIGUOUS_STR)
randomSymbol in PasswordGenerator.AMBIGUOUS_STR)
password += randomSymbol password += randomSymbol
// Continue the password generation as if nothing was added. // Continue the password generation as if nothing was added.
} }
// Third part: Determine the basic type of the next character depending on the letter // Third part: Determine the basic type of the next character depending on the letter
// we just added. // we just added.
nextBasicType = when { nextBasicType =
when {
candidate.flags.hasFlag(CONSONANT) -> VOWEL candidate.flags.hasFlag(CONSONANT) -> VOWEL
previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) || previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) || secureRandomBiasedBoolean(60) ->
secureRandomBiasedBoolean(60) -> CONSONANT CONSONANT
else -> VOWEL else -> VOWEL
} }
previousFlags = candidate.flags previousFlags = candidate.flags

View file

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

View file

@ -29,25 +29,15 @@ class PasswordBuilder(ctx: Context) {
private var isPrependWithSeparator = false private var isPrependWithSeparator = false
private var isAppendNumberSeparator = false private var isAppendNumberSeparator = false
fun setNumberOfWords(amount: Int) = apply { fun setNumberOfWords(amount: Int) = apply { numWords = amount }
numWords = amount
}
fun setMinimumWordLength(min: Int) = apply { fun setMinimumWordLength(min: Int) = apply { minWordLength = min }
minWordLength = min
}
fun setMaximumWordLength(max: Int) = apply { fun setMaximumWordLength(max: Int) = apply { maxWordLength = max }
maxWordLength = max
}
fun setSeparator(separator: String) = apply { fun setSeparator(separator: String) = apply { this.separator = separator }
this.separator = separator
}
fun setCapitalization(capitalizationScheme: CapsType) = apply { fun setCapitalization(capitalizationScheme: CapsType) = apply { capsType = capitalizationScheme }
capsType = capitalizationScheme
}
@JvmOverloads @JvmOverloads
fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply { fun prependNumbers(numDigits: Int, addSeparator: Boolean = true) = apply {
@ -102,12 +92,15 @@ class PasswordBuilder(ctx: Context) {
} }
if (wordBank.size == 0) { if (wordBank.size == 0) {
throw PasswordGeneratorException(context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength)) throw PasswordGeneratorException(
context.getString(R.string.xkpwgen_builder_error, minWordLength, maxWordLength)
)
} }
for (i in 0 until numWords) { for (i in 0 until numWords) {
val candidate = wordBank.secureRandomElement() val candidate = wordBank.secureRandomElement()
val s = when (capsType) { val s =
when (capsType) {
CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault()) CapsType.UPPERCASE -> candidate.toUpperCase(Locale.getDefault())
CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate CapsType.Sentence -> if (i == 0) candidate.capitalize(Locale.getDefault()) else candidate
CapsType.TitleCase -> candidate.capitalize(Locale.getDefault()) CapsType.TitleCase -> candidate.capitalize(Locale.getDefault())

View file

@ -20,17 +20,17 @@ class XkpwdDictionary(context: Context) {
val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: "" val uri = prefs.getString(PreferenceKeys.PREF_KEY_CUSTOM_DICT) ?: ""
val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE) val customDictFile = File(context.filesDir, XKPWD_CUSTOM_DICT_FILE)
val lines = if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) && val lines =
uri.isNotEmpty() && customDictFile.canRead()) { if (prefs.getBoolean(PreferenceKeys.PREF_KEY_IS_CUSTOM_DICT, false) &&
uri.isNotEmpty() &&
customDictFile.canRead()
) {
customDictFile.readLines() customDictFile.readLines()
} else { } else {
context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines() context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines()
} }
words = lines.asSequence() words = lines.asSequence().map { it.trim() }.filter { it.isNotEmpty() && !it.contains(' ') }.groupBy { it.length }
.map { it.trim() }
.filter { it.isNotEmpty() && !it.contains(' ') }
.groupBy { it.length }
} }
companion object { companion object {

View file

@ -43,7 +43,6 @@ class ClipboardService : Service() {
stopSelf() stopSelf()
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
ACTION_START -> { ACTION_START -> {
val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45) val time = intent.getIntExtra(EXTRA_NOTIFICATION_TIME, 45)
@ -53,9 +52,7 @@ class ClipboardService : Service() {
createNotification(time) createNotification(time)
scope.launch { scope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { startTimer(time) }
startTimer(time)
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
clearClipboard() clearClipboard()
stopForeground(true) stopForeground(true)
@ -113,15 +110,15 @@ class ClipboardService : Service() {
private fun createNotification(clearTime: Int) { private fun createNotification(clearTime: Int) {
val clearTimeMs = clearTime * 1000L val clearTimeMs = clearTime * 1000L
val clearIntent = Intent(this, ClipboardService::class.java).apply { val clearIntent = Intent(this, ClipboardService::class.java).apply { action = ACTION_CLEAR }
action = ACTION_CLEAR val pendingIntent =
} if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) PendingIntent.getForegroundService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
} else { } else {
PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT) PendingIntent.getService(this, 0, clearIntent, PendingIntent.FLAG_UPDATE_CURRENT)
} }
val notification = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { val notification =
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
createNotificationApi23(pendingIntent) createNotificationApi23(pendingIntent)
} else { } else {
createNotificationApi24(pendingIntent, clearTimeMs) createNotificationApi24(pendingIntent, clearTimeMs)
@ -159,11 +156,8 @@ class ClipboardService : Service() {
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel( val serviceChannel =
CHANNEL_ID, NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW)
getString(R.string.app_name),
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService<NotificationManager>() val manager = getSystemService<NotificationManager>()
if (manager != null) { if (manager != null) {
manager.createNotificationChannel(serviceChannel) manager.createNotificationChannel(serviceChannel)
@ -179,7 +173,8 @@ class ClipboardService : Service() {
const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME" const val EXTRA_NOTIFICATION_TIME = "EXTRA_NOTIFICATION_TIME"
private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD" private const val ACTION_CLEAR = "ACTION_CLEAR_CLIPBOARD"
private const val CHANNEL_ID = "NotificationService" 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, // Newest Samsung phones now feature a history of up to 30 items. To err on the side of
// caution,
// push 35 fake ones. // push 35 fake ones.
private const val CLIPBOARD_CLEAR_COUNT = 35 private const val CLIPBOARD_CLEAR_COUNT = 35
} }

View file

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

View file

@ -64,10 +64,9 @@ class PasswordExportService : Service() {
d { "Copying ${repositoryDirectory.path} to $targetDirectory" } d { "Copying ${repositoryDirectory.path} to $targetDirectory" }
val dateString = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val dateString =
LocalDateTime if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
.now() LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
.format(DateTimeFormatter.ISO_DATE_TIME)
} else { } else {
String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z"))) String.format("%tFT%<tRZ", Calendar.getInstance(TimeZone.getTimeZone("Z")))
} }
@ -124,7 +123,8 @@ class PasswordExportService : Service() {
private fun createNotification() { private fun createNotification() {
createNotificationChannel() createNotificationChannel()
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification =
NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name)) .setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.exporting_passwords)) .setContentText(getString(R.string.exporting_passwords))
.setSmallIcon(R.drawable.ic_round_import_export) .setSmallIcon(R.drawable.ic_round_import_export)
@ -136,11 +136,8 @@ class PasswordExportService : Service() {
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel( val serviceChannel =
CHANNEL_ID, NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW)
getString(R.string.app_name),
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService<NotificationManager>() val manager = getSystemService<NotificationManager>()
if (manager != null) { if (manager != null) {
manager.createNotificationChannel(serviceChannel) manager.createNotificationChannel(serviceChannel)

View file

@ -25,8 +25,7 @@ enum class Protocol(val pref: String) {
private val map = values().associateBy(Protocol::pref) private val map = values().associateBy(Protocol::pref)
fun fromString(type: String?): Protocol { fun fromString(type: String?): Protocol {
return map[type ?: return Ssh] return map[type ?: return Ssh] ?: throw IllegalArgumentException("$type is not a valid Protocol")
?: throw IllegalArgumentException("$type is not a valid Protocol")
} }
} }
} }
@ -42,8 +41,7 @@ enum class AuthMode(val pref: String) {
private val map = values().associateBy(AuthMode::pref) private val map = values().associateBy(AuthMode::pref)
fun fromString(type: String?): AuthMode { fun fromString(type: String?): AuthMode {
return map[type ?: return SshKey] return map[type ?: return SshKey] ?: throw IllegalArgumentException("$type is not a valid AuthMode")
?: throw IllegalArgumentException("$type is not a valid AuthMode")
} }
} }
} }
@ -53,29 +51,25 @@ object GitSettings {
private const val DEFAULT_BRANCH = "master" private const val DEFAULT_BRANCH = "master"
private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs } private val settings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.sharedPrefs }
private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedGitPrefs() } private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) {
Application.instance.getEncryptedGitPrefs()
}
private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() } private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() }
private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" } private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" }
var authMode var authMode
get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH)) get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
private set(value) { private set(value) {
settings.edit { settings.edit { putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref) }
putString(PreferenceKeys.GIT_REMOTE_AUTH, value.pref)
}
} }
var url var url
get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL) get() = settings.getString(PreferenceKeys.GIT_REMOTE_URL)
private set(value) { private set(value) {
require(value != null) require(value != null)
if (value == url) if (value == url) return
return settings.edit { putString(PreferenceKeys.GIT_REMOTE_URL, value) }
settings.edit { if (PasswordRepository.isInitialized) PasswordRepository.addRemote("origin", value, true)
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 // When the server changes, remote password, multiplexing support and host key file
// should be deleted/reset. // should be deleted/reset.
useMultiplexing = true useMultiplexing = true
@ -86,89 +80,76 @@ object GitSettings {
var authorName var authorName
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: "" get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME) ?: ""
set(value) { set(value) {
settings.edit { settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value) }
putString(PreferenceKeys.GIT_CONFIG_AUTHOR_NAME, value)
}
} }
var authorEmail var authorEmail
get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: "" get() = settings.getString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL) ?: ""
set(value) { set(value) {
settings.edit { settings.edit { putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value) }
putString(PreferenceKeys.GIT_CONFIG_AUTHOR_EMAIL, value)
}
} }
var branch var branch
get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH get() = settings.getString(PreferenceKeys.GIT_BRANCH_NAME) ?: DEFAULT_BRANCH
private set(value) { private set(value) {
settings.edit { settings.edit { putString(PreferenceKeys.GIT_BRANCH_NAME, value) }
putString(PreferenceKeys.GIT_BRANCH_NAME, value)
}
} }
var useMultiplexing var useMultiplexing
get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true) get() = settings.getBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, true)
set(value) { set(value) {
settings.edit { settings.edit { putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value) }
putBoolean(PreferenceKeys.GIT_REMOTE_USE_MULTIPLEXING, value)
}
} }
var proxyHost var proxyHost
get() = proxySettings.getString(PreferenceKeys.PROXY_HOST) get() = proxySettings.getString(PreferenceKeys.PROXY_HOST)
set(value) { set(value) {
proxySettings.edit { proxySettings.edit { putString(PreferenceKeys.PROXY_HOST, value) }
putString(PreferenceKeys.PROXY_HOST, value)
}
} }
var proxyPort var proxyPort
get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1) get() = proxySettings.getInt(PreferenceKeys.PROXY_PORT, -1)
set(value) { set(value) {
proxySettings.edit { proxySettings.edit { putInt(PreferenceKeys.PROXY_PORT, value) }
putInt(PreferenceKeys.PROXY_PORT, value)
}
} }
var proxyUsername var proxyUsername
get() = settings.getString(PreferenceKeys.PROXY_USERNAME) get() = settings.getString(PreferenceKeys.PROXY_USERNAME)
set(value) { set(value) {
proxySettings.edit { proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) }
putString(PreferenceKeys.PROXY_USERNAME, value)
}
} }
var proxyPassword var proxyPassword
get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD) get() = proxySettings.getString(PreferenceKeys.PROXY_PASSWORD)
set(value) { set(value) {
proxySettings.edit { proxySettings.edit { putString(PreferenceKeys.PROXY_PASSWORD, value) }
putString(PreferenceKeys.PROXY_PASSWORD, value)
}
} }
var rebaseOnPull var rebaseOnPull
get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true) get() = settings.getBoolean(PreferenceKeys.REBASE_ON_PULL, true)
set(value) { set(value) {
settings.edit { settings.edit { putBoolean(PreferenceKeys.REBASE_ON_PULL, value) }
putBoolean(PreferenceKeys.REBASE_ON_PULL, value)
}
} }
sealed class UpdateConnectionSettingsResult { sealed class UpdateConnectionSettingsResult {
class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult() class MissingUsername(val newProtocol: Protocol) : UpdateConnectionSettingsResult()
class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) : UpdateConnectionSettingsResult() class AuthModeMismatch(val newProtocol: Protocol, val validModes: List<AuthMode>) :
UpdateConnectionSettingsResult()
object Valid : UpdateConnectionSettingsResult() object Valid : UpdateConnectionSettingsResult()
object FailedToParseUrl : UpdateConnectionSettingsResult() object FailedToParseUrl : UpdateConnectionSettingsResult()
} }
fun updateConnectionSettingsIfValid(newAuthMode: AuthMode, newUrl: String, newBranch: String): UpdateConnectionSettingsResult { fun updateConnectionSettingsIfValid(
val parsedUrl = runCatching { newAuthMode: AuthMode,
URIish(newUrl) newUrl: String,
}.getOrElse { newBranch: String
): UpdateConnectionSettingsResult {
val parsedUrl =
runCatching { URIish(newUrl) }.getOrElse {
return UpdateConnectionSettingsResult.FailedToParseUrl return UpdateConnectionSettingsResult.FailedToParseUrl
} }
val newProtocol = when (parsedUrl.scheme) { val newProtocol =
when (parsedUrl.scheme) {
in listOf("http", "https") -> Protocol.Https in listOf("http", "https") -> Protocol.Https
in listOf("ssh", null) -> Protocol.Ssh in listOf("ssh", null) -> Protocol.Ssh
else -> return UpdateConnectionSettingsResult.FailedToParseUrl else -> return UpdateConnectionSettingsResult.FailedToParseUrl
@ -193,15 +174,11 @@ object GitSettings {
return UpdateConnectionSettingsResult.Valid return UpdateConnectionSettingsResult.Valid
} }
/** /** Deletes a previously saved SSH host key */
* Deletes a previously saved SSH host key
*/
fun clearSavedHostKey() { fun clearSavedHostKey() {
File(hostKeyPath).delete() File(hostKeyPath).delete()
} }
/** /** Returns true if a host key was previously saved */
* Returns true if a host key was previously saved
*/
fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists() fun hasSavedHostKey(): Boolean = File(hostKeyPath).exists()
} }

View file

@ -28,8 +28,7 @@ fun runMigrations(context: Context) {
} }
private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) { private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) {
val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) val serverHostname = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_SERVER) ?: return
?: return
i { "Migrating to URL-based Git config" } i { "Migrating to URL-based Git config" }
val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: "" val serverPort = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_PORT) ?: ""
val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: "" val serverUser = sharedPrefs.getString(PreferenceKeys.GIT_REMOTE_USERNAME) ?: ""
@ -38,17 +37,16 @@ private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) {
// Whether we need the leading ssh:// depends on the use of a custom port. // Whether we need the leading ssh:// depends on the use of a custom port.
val hostnamePart = serverHostname.removePrefix("ssh://") val hostnamePart = serverHostname.removePrefix("ssh://")
val url = when (protocol) { val url =
when (protocol) {
Protocol.Ssh -> { Protocol.Ssh -> {
val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@" val userPart = if (serverUser.isEmpty()) "" else "${serverUser.trimEnd('@')}@"
val portPart = val portPart = if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort"
if (serverPort == "22" || serverPort.isEmpty()) "" else ":$serverPort"
if (portPart.isEmpty()) { if (portPart.isEmpty()) {
"$userPart$hostnamePart:$serverPath" "$userPart$hostnamePart:$serverPath"
} else { } else {
// Only absolute paths are supported with custom ports. // Only absolute paths are supported with custom ports.
if (!serverPath.startsWith('/')) if (!serverPath.startsWith('/')) null
null
else else
// We have to specify the ssh scheme as this is the only way to pass a custom // We have to specify the ssh scheme as this is the only way to pass a custom
// port. // port.
@ -56,21 +54,16 @@ private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) {
} }
} }
Protocol.Https -> { Protocol.Https -> {
val portPart = val portPart = if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort"
if (serverPort == "443" || serverPort.isEmpty()) "" else ":$serverPort"
val pathPart = serverPath.trimStart('/', ':') val pathPart = serverPath.trimStart('/', ':')
val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart" val urlWithFreeEntryScheme = "$hostnamePart$portPart/$pathPart"
val url = when { val url =
when {
urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme urlWithFreeEntryScheme.startsWith("https://") -> urlWithFreeEntryScheme
urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https") urlWithFreeEntryScheme.startsWith("http://") -> urlWithFreeEntryScheme.replaceFirst("http", "https")
else -> "https://$urlWithFreeEntryScheme" else -> "https://$urlWithFreeEntryScheme"
} }
runCatching { runCatching { if (URI(url).rawAuthority != null) url else null }.get()
if (URI(url).rawAuthority != null)
url
else
null
}.get()
} }
} }
@ -81,10 +74,13 @@ private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) {
remove(PreferenceKeys.GIT_REMOTE_USERNAME) remove(PreferenceKeys.GIT_REMOTE_USERNAME)
remove(PreferenceKeys.GIT_REMOTE_PROTOCOL) remove(PreferenceKeys.GIT_REMOTE_PROTOCOL)
} }
if (url == null || GitSettings.updateConnectionSettingsIfValid( if (url == null ||
GitSettings.updateConnectionSettingsIfValid(
newAuthMode = GitSettings.authMode, newAuthMode = GitSettings.authMode,
newUrl = url, newUrl = url,
newBranch = GitSettings.branch) != GitSettings.UpdateConnectionSettingsResult.Valid) { newBranch = GitSettings.branch
) != GitSettings.UpdateConnectionSettingsResult.Valid
) {
e { "Failed to migrate to URL-based Git config, generated URL is invalid" } e { "Failed to migrate to URL-based Git config, generated URL is invalid" }
} }
} }
@ -100,17 +96,13 @@ private fun migrateToHideAll(sharedPrefs: SharedPreferences) {
private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) { private fun migrateToSshKey(context: Context, sharedPrefs: SharedPreferences) {
val privateKeyFile = File(context.filesDir, ".ssh_key") val privateKeyFile = File(context.filesDir, ".ssh_key")
if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) && !SshKey.exists && privateKeyFile.exists()) {
!SshKey.exists &&
privateKeyFile.exists()) {
// Currently uses a private key imported or generated with an old version of Password Store. // 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 // 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. // the migration (not possible for regular imported keys), hence the special case.
val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false) val isGeneratedKey = sharedPrefs.getBoolean(PreferenceKeys.USE_GENERATED_KEY, false)
SshKey.useLegacyKey(isGeneratedKey) SshKey.useLegacyKey(isGeneratedKey)
sharedPrefs.edit { sharedPrefs.edit { remove(PreferenceKeys.USE_GENERATED_KEY) }
remove(PreferenceKeys.USE_GENERATED_KEY)
}
} }
} }

View file

@ -13,17 +13,14 @@ import dev.msfjarvis.aps.util.extensions.base64
import dev.msfjarvis.aps.util.extensions.getString import dev.msfjarvis.aps.util.extensions.getString
enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) { enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>) {
FOLDER_FIRST(
FOLDER_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem -> Comparator { p1: PasswordItem, p2: PasswordItem ->
(p1.type + p1.name) (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true)
.compareTo(p2.type + p2.name, ignoreCase = true) }
}), ),
INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem -> p1.name.compareTo(p2.name, ignoreCase = true) }),
INDEPENDENT(Comparator { p1: PasswordItem, p2: PasswordItem -> RECENTLY_USED(
p1.name.compareTo(p2.name, ignoreCase = true) Comparator { p1: PasswordItem, p2: PasswordItem ->
}),
RECENTLY_USED(Comparator { p1: PasswordItem, p2: PasswordItem ->
val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE) val recentHistory = Application.instance.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
val timeP1 = recentHistory.getString(p1.file.absolutePath.base64()) val timeP1 = recentHistory.getString(p1.file.absolutePath.base64())
val timeP2 = recentHistory.getString(p2.file.absolutePath.base64()) val timeP2 = recentHistory.getString(p2.file.absolutePath.base64())
@ -33,11 +30,13 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>)
timeP1 == null && timeP2 != null -> return@Comparator 1 timeP1 == null && timeP2 != null -> return@Comparator 1
else -> p1.name.compareTo(p2.name, ignoreCase = true) else -> p1.name.compareTo(p2.name, ignoreCase = true)
} }
}), }
),
FILE_FIRST(Comparator { p1: PasswordItem, p2: PasswordItem -> FILE_FIRST(
Comparator { p1: PasswordItem, p2: PasswordItem ->
(p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true) (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true)
}); }
);
companion object { companion object {

View file

@ -31,23 +31,18 @@ object PreferenceKeys {
const val GIT_REMOTE_AUTH = "git_remote_auth" const val GIT_REMOTE_AUTH = "git_remote_auth"
const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type" const val GIT_REMOTE_KEY_TYPE = "git_remote_key_type"
@Deprecated("Use GIT_REMOTE_URL instead") @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_LOCATION = "git_remote_location"
const val GIT_REMOTE_LOCATION = "git_remote_location"
const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing" const val GIT_REMOTE_USE_MULTIPLEXING = "git_remote_use_multiplexing"
@Deprecated("Use GIT_REMOTE_URL instead") @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PORT = "git_remote_port"
const val GIT_REMOTE_PORT = "git_remote_port"
@Deprecated("Use GIT_REMOTE_URL instead") @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_PROTOCOL = "git_remote_protocol"
const val GIT_REMOTE_PROTOCOL = "git_remote_protocol"
const val GIT_DELETE_REPO = "git_delete_repo" const val GIT_DELETE_REPO = "git_delete_repo"
@Deprecated("Use GIT_REMOTE_URL instead") @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_SERVER = "git_remote_server"
const val GIT_REMOTE_SERVER = "git_remote_server"
const val GIT_REMOTE_URL = "git_remote_url" const val GIT_REMOTE_URL = "git_remote_url"
@Deprecated("Use GIT_REMOTE_URL instead") @Deprecated("Use GIT_REMOTE_URL instead") const val GIT_REMOTE_USERNAME = "git_remote_username"
const val GIT_REMOTE_USERNAME = "git_remote_username"
const val GIT_SERVER_INFO = "git_server_info" const val GIT_SERVER_INFO = "git_server_info"
const val GIT_BRANCH_NAME = "git_branch" const val GIT_BRANCH_NAME = "git_branch"
const val HTTPS_PASSWORD = "https_password" const val HTTPS_PASSWORD = "https_password"
@ -78,8 +73,7 @@ object PreferenceKeys {
const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid" const val SSH_OPENKEYSTORE_KEYID = "ssh_openkeystore_keyid"
const val SSH_SEE_KEY = "ssh_see_key" const val SSH_SEE_KEY = "ssh_see_key"
@Deprecated("To be used only in Migrations.kt") @Deprecated("To be used only in Migrations.kt") const val USE_GENERATED_KEY = "use_generated_key"
const val USE_GENERATED_KEY = "use_generated_key"
const val PROXY_SETTINGS = "proxy_settings" const val PROXY_SETTINGS = "proxy_settings"
const val PROXY_HOST = "proxy_host" const val PROXY_HOST = "proxy_host"

View file

@ -27,7 +27,8 @@ object Otp {
val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}" val algo = "Hmac${algorithm.toUpperCase(Locale.ROOT)}"
val decodedSecret = BASE_32.decode(secret) val decodedSecret = BASE_32.decode(secret)
val secretKey = SecretKeySpec(decodedSecret, algo) val secretKey = SecretKeySpec(decodedSecret, algo)
val digest = Mac.getInstance(algo).run { val digest =
Mac.getInstance(algo).run {
init(secretKey) init(secretKey)
doFinal(ByteBuffer.allocate(8).putLong(counter).array()) doFinal(ByteBuffer.allocate(8).putLong(counter).array())
} }

View file

@ -5,28 +5,18 @@
package dev.msfjarvis.aps.util.totp package dev.msfjarvis.aps.util.totp
/** /** Defines a class that can extract relevant parts of a TOTP URL for use by the app. */
* Defines a class that can extract relevant parts of a TOTP URL for use by the app.
*/
interface TotpFinder { interface TotpFinder {
/** /** Get the TOTP secret from the given extra content. */
* Get the TOTP secret from the given extra content.
*/
fun findSecret(content: String): String? fun findSecret(content: String): String?
/** /** Get the number of digits required in the final OTP. */
* Get the number of digits required in the final OTP.
*/
fun findDigits(content: String): String fun findDigits(content: String): String
/** /** Get the TOTP timeout period. */
* Get the TOTP timeout period.
*/
fun findPeriod(content: String): Long fun findPeriod(content: String): Long
/** /** Get the algorithm for the TOTP secret. */
* Get the algorithm for the TOTP secret.
*/
fun findAlgorithm(content: String): String fun findAlgorithm(content: String): String
} }

View file

@ -7,9 +7,7 @@ package dev.msfjarvis.aps.util.totp
import android.net.Uri import android.net.Uri
/** /** [Uri] backed TOTP URL parser. */
* [Uri] backed TOTP URL parser.
*/
class UriTotpFinder : TotpFinder { class UriTotpFinder : TotpFinder {
override fun findSecret(content: String): String? { override fun findSecret(content: String): String? {
@ -26,8 +24,7 @@ class UriTotpFinder : TotpFinder {
override fun findDigits(content: String): String { override fun findDigits(content: String): String {
content.split("\n".toRegex()).forEach { line -> content.split("\n".toRegex()).forEach { line ->
if (line.startsWith(TOTP_FIELDS[0]) && if (line.startsWith(TOTP_FIELDS[0]) && Uri.parse(line).getQueryParameter("digits") != null) {
Uri.parse(line).getQueryParameter("digits") != null) {
return Uri.parse(line).getQueryParameter("digits")!! return Uri.parse(line).getQueryParameter("digits")!!
} }
} }
@ -36,11 +33,9 @@ class UriTotpFinder : TotpFinder {
override fun findPeriod(content: String): Long { override fun findPeriod(content: String): Long {
content.split("\n".toRegex()).forEach { line -> content.split("\n".toRegex()).forEach { line ->
if (line.startsWith(TOTP_FIELDS[0]) && if (line.startsWith(TOTP_FIELDS[0]) && Uri.parse(line).getQueryParameter("period") != null) {
Uri.parse(line).getQueryParameter("period") != null) {
val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull() val period = Uri.parse(line).getQueryParameter("period")!!.toLongOrNull()
if (period != null && period > 0) if (period != null && period > 0) return period
return period
} }
} }
return 30 return 30
@ -48,8 +43,7 @@ class UriTotpFinder : TotpFinder {
override fun findAlgorithm(content: String): String { override fun findAlgorithm(content: String): String {
content.split("\n".toRegex()).forEach { line -> content.split("\n".toRegex()).forEach { line ->
if (line.startsWith(TOTP_FIELDS[0]) && if (line.startsWith(TOTP_FIELDS[0]) && Uri.parse(line).getQueryParameter("algorithm") != null) {
Uri.parse(line).getQueryParameter("algorithm") != null) {
return Uri.parse(line).getQueryParameter("algorithm")!! return Uri.parse(line).getQueryParameter("algorithm")!!
} }
} }
@ -58,9 +52,6 @@ class UriTotpFinder : TotpFinder {
companion object { companion object {
val TOTP_FIELDS = arrayOf( val TOTP_FIELDS = arrayOf("otpauth://totp", "totp:")
"otpauth://totp",
"totp:"
)
} }
} }

View file

@ -50,10 +50,9 @@ import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import me.zhanghai.android.fastscroll.PopupTextProvider import me.zhanghai.android.fastscroll.PopupTextProvider
private fun File.toPasswordItem() = if (isFile) private fun File.toPasswordItem() =
PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory()) if (isFile) PasswordItem.newPassword(name, this, PasswordRepository.getRepositoryDirectory())
else else PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory())
PasswordItem.newCategory(name, this, PasswordRepository.getRepositoryDirectory())
private fun PasswordItem.fuzzyMatch(filter: String): Int { private fun PasswordItem.fuzzyMatch(filter: String): Int {
var i = 0 var i = 0
@ -83,9 +82,7 @@ private fun PasswordItem.fuzzyMatch(filter: String): Int {
return if (i == filter.length) score else 0 return if (i == filter.length) score else 0
} }
private val CaseInsensitiveComparator = Collator.getInstance().apply { private val CaseInsensitiveComparator = Collator.getInstance().apply { strength = Collator.PRIMARY }
strength = Collator.PRIMARY
}
private fun PasswordItem.Companion.makeComparator( private fun PasswordItem.Companion.makeComparator(
typeSortOrder: PasswordSortOrder, typeSortOrder: PasswordSortOrder,
@ -93,18 +90,15 @@ private fun PasswordItem.Companion.makeComparator(
): Comparator<PasswordItem> { ): Comparator<PasswordItem> {
return when (typeSortOrder) { return when (typeSortOrder) {
PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type }
// In order to let INDEPENDENT not distinguish between items based on their type, we simply // In order to let INDEPENDENT not distinguish between items based on their type, we
// simply
// declare them all equal at this stage. // declare them all equal at this stage.
PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 } PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 }
PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type }
PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator
} }
.then(compareBy(nullsLast(CaseInsensitiveComparator)) { .then(compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getIdentifierFor(it.file) })
directoryStructure.getIdentifierFor(it.file) .then(compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getUsernameFor(it.file) })
})
.then(compareBy(nullsLast(CaseInsensitiveComparator)) {
directoryStructure.getUsernameFor(it.file)
})
} }
val PasswordItem.stableId: String val PasswordItem.stableId: String
@ -144,7 +138,8 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
private val showHiddenContents private val showHiddenContents
get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false) get() = settings.getBoolean(PreferenceKeys.SHOW_HIDDEN_CONTENTS, false)
private val defaultSearchMode private val defaultSearchMode
get() = if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) { get() =
if (settings.getBoolean(PreferenceKeys.FILTER_RECURSIVELY, true)) {
SearchMode.RecursivelyInSubdirectories SearchMode.RecursivelyInSubdirectories
} else { } else {
SearchMode.InCurrentDirectoryOnly SearchMode.InCurrentDirectoryOnly
@ -185,10 +180,10 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
) )
} }
private fun updateSearchAction(action: SearchAction) = private fun updateSearchAction(action: SearchAction) = action.copy(updateCounter = updateCounter)
action.copy(updateCounter = updateCounter)
private val searchAction = MutableLiveData( private val searchAction =
MutableLiveData(
makeSearchAction( makeSearchAction(
baseDirectory = root, baseDirectory = root,
filter = "", filter = "",
@ -201,34 +196,34 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean) data class SearchResult(val passwordItems: List<PasswordItem>, val isFiltered: Boolean)
val searchResult = searchActionFlow val searchResult =
searchActionFlow
.mapLatest { searchAction -> .mapLatest { searchAction ->
val listResultFlow = when (searchAction.searchMode) { val listResultFlow =
when (searchAction.searchMode) {
SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory) SearchMode.RecursivelyInSubdirectories -> listFilesRecursively(searchAction.baseDirectory)
SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory) SearchMode.InCurrentDirectoryOnly -> listFiles(searchAction.baseDirectory)
} }
val prefilteredResultFlow = when (searchAction.listMode) { val prefilteredResultFlow =
when (searchAction.listMode) {
ListMode.FilesOnly -> listResultFlow.filter { it.isFile } ListMode.FilesOnly -> listResultFlow.filter { it.isFile }
ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory } ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory }
ListMode.AllEntries -> listResultFlow ListMode.AllEntries -> listResultFlow
} }
val filterModeToUse = val filterModeToUse = if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode
if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode val passwordList =
val passwordList = when (filterModeToUse) { when (filterModeToUse) {
FilterMode.NoFilter -> { FilterMode.NoFilter -> {
prefilteredResultFlow prefilteredResultFlow.map { it.toPasswordItem() }.toList().sortedWith(itemComparator)
.map { it.toPasswordItem() }
.toList()
.sortedWith(itemComparator)
} }
FilterMode.StrictDomain -> { FilterMode.StrictDomain -> {
check(searchAction.listMode == ListMode.FilesOnly) { "Searches with StrictDomain search mode can only list files" } check(searchAction.listMode == ListMode.FilesOnly) {
"Searches with StrictDomain search mode can only list files"
}
val regex = generateStrictDomainRegex(searchAction.filter) val regex = generateStrictDomainRegex(searchAction.filter)
if (regex != null) { if (regex != null) {
prefilteredResultFlow prefilteredResultFlow
.filter { absoluteFile -> .filter { absoluteFile -> regex.containsMatchIn(absoluteFile.relativeTo(root).path) }
regex.containsMatchIn(absoluteFile.relativeTo(root).path)
}
.map { it.toPasswordItem() } .map { it.toPasswordItem() }
.toList() .toList()
.sortedWith(itemComparator) .sortedWith(itemComparator)
@ -245,16 +240,17 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
.filter { it.first > 0 } .filter { it.first > 0 }
.toList() .toList()
.sortedWith( .sortedWith(
compareByDescending<Pair<Int, PasswordItem>> { it.first }.thenBy( compareByDescending<Pair<Int, PasswordItem>> { it.first }.thenBy(itemComparator) { it.second }
itemComparator )
) { it.second })
.map { it.second } .map { it.second }
} }
} }
SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter) SearchResult(passwordList, isFiltered = searchAction.filterMode != FilterMode.NoFilter)
}.asLiveData(Dispatchers.IO) }
.asLiveData(Dispatchers.IO)
private fun shouldTake(file: File) = with(file) { private fun shouldTake(file: File) =
with(file) {
if (showHiddenContents) return true if (showHiddenContents) return true
if (isDirectory) { if (isDirectory) {
!isHidden !isHidden
@ -270,7 +266,8 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
private fun listFilesRecursively(dir: File): Flow<File> { private fun listFilesRecursively(dir: File): Flow<File> {
return dir return dir
// Take top directory even if it is hidden. // Take top directory even if it is hidden.
.walkTopDown().onEnter { file -> file == dir || shouldTake(file) } .walkTopDown()
.onEnter { file -> file == dir || shouldTake(file) }
.asFlow() .asFlow()
// Skip the root directory // Skip the root directory
.drop(1) .drop(1)
@ -362,8 +359,7 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
@VisibleForTesting @VisibleForTesting
fun generateStrictDomainRegex(domain: String): Regex? { fun generateStrictDomainRegex(domain: String): Regex? {
// Valid domains do not contain path separators. // Valid domains do not contain path separators.
if (domain.contains('/')) if (domain.contains('/')) return null
return null
// Matches the start of a path component, which is either the start of the // Matches the start of a path component, which is either the start of the
// string or a path separator. // string or a path separator.
val prefix = """(?:^|/)""" val prefix = """(?:^|/)"""
@ -386,8 +382,7 @@ private object PasswordItemDiffCallback : DiffUtil.ItemCallback<PasswordItem>()
override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = override fun areItemsTheSame(oldItem: PasswordItem, newItem: PasswordItem) =
oldItem.file.absolutePath == newItem.file.absolutePath oldItem.file.absolutePath == newItem.file.absolutePath
override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = override fun areContentsTheSame(oldItem: PasswordItem, newItem: PasswordItem) = oldItem == newItem
oldItem == newItem
} }
open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>( open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
@ -400,20 +395,24 @@ open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
recyclerView: RecyclerView, recyclerView: RecyclerView,
itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T itemDetailsLookupCreator: (recyclerView: RecyclerView) -> T
) { ) {
selectionTracker = SelectionTracker.Builder( selectionTracker =
SelectionTracker.Builder(
"SearchableRepositoryAdapter", "SearchableRepositoryAdapter",
recyclerView, recyclerView,
itemKeyProvider, itemKeyProvider,
itemDetailsLookupCreator(recyclerView), itemDetailsLookupCreator(recyclerView),
StorageStrategy.createStringStorage() StorageStrategy.createStringStorage()
).withSelectionPredicate(SelectionPredicates.createSelectAnything()).build().apply {
addObserver(object : SelectionTracker.SelectionObserver<String>() {
override fun onSelectionChanged() {
this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(
requireSelectionTracker().selection
) )
.withSelectionPredicate(SelectionPredicates.createSelectAnything())
.build()
.apply {
addObserver(
object : SelectionTracker.SelectionObserver<String>() {
override fun onSelectionChanged() {
this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(requireSelectionTracker().selection)
} }
}) }
)
} }
} }
@ -431,12 +430,12 @@ open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
return this return this
} }
private val itemKeyProvider = object : ItemKeyProvider<String>(SCOPE_MAPPED) { private val itemKeyProvider =
object : ItemKeyProvider<String>(SCOPE_MAPPED) {
override fun getKey(position: Int) = getItem(position).stableId override fun getKey(position: Int) = getItem(position).stableId
override fun getPosition(key: String) = override fun getPosition(key: String) =
(0 until itemCount).firstOrNull { getItem(it).stableId == key } (0 until itemCount).firstOrNull { getItem(it).stableId == key } ?: RecyclerView.NO_POSITION
?: RecyclerView.NO_POSITION
} }
private var selectionTracker: SelectionTracker<String>? = null private var selectionTracker: SelectionTracker<String>? = null
@ -450,8 +449,7 @@ open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath) fun getPositionForFile(file: File) = itemKeyProvider.getPosition(file.absolutePath)
final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T { final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): T {
val view = LayoutInflater.from(parent.context) val view = LayoutInflater.from(parent.context).inflate(layoutRes, parent, false)
.inflate(layoutRes, parent, false)
return viewHolderCreator(view) return viewHolderCreator(view)
} }
@ -462,8 +460,7 @@ open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) } selectionTracker?.let { itemView.isSelected = it.isSelected(item.stableId) }
itemView.setOnClickListener { itemView.setOnClickListener {
// Do not emit custom click events while the user is selecting items. // Do not emit custom click events while the user is selecting items.
if (selectionTracker?.hasSelection() != true) if (selectionTracker?.hasSelection() != true) onItemClickedListener?.invoke(holder, item)
onItemClickedListener?.invoke(holder, item)
} }
} }
} }

View file

@ -29,8 +29,8 @@ import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.api.ResolvableApiException import com.google.android.gms.common.api.ResolvableApiException
import com.google.android.gms.tasks.Task import com.google.android.gms.tasks.Task
import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder
import dev.msfjarvis.aps.databinding.ActivityOreoAutofillSmsBinding import dev.msfjarvis.aps.databinding.ActivityOreoAutofillSmsBinding
import dev.msfjarvis.aps.util.autofill.AutofillResponseBuilder
import dev.msfjarvis.aps.util.extensions.viewBinding import dev.msfjarvis.aps.util.extensions.viewBinding
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import kotlin.coroutines.resume import kotlin.coroutines.resume
@ -40,16 +40,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
suspend fun <T> Task<T>.suspendableAwait() = suspendCoroutine<T> { cont -> suspend fun <T> Task<T>.suspendableAwait() =
addOnSuccessListener { result: T -> suspendCoroutine<T> { cont ->
cont.resume(result) addOnSuccessListener { result: T -> cont.resume(result) }
}
addOnFailureListener { e -> addOnFailureListener { e ->
// Unwrap specific exceptions (e.g. ResolvableApiException) from ExecutionException. // Unwrap specific exceptions (e.g. ResolvableApiException) from ExecutionException.
val cause = (e as? ExecutionException)?.cause ?: e val cause = (e as? ExecutionException)?.cause ?: e
cont.resumeWithException(cause) cont.resumeWithException(cause)
} }
} }
@RequiresApi(Build.VERSION_CODES.O) @RequiresApi(Build.VERSION_CODES.O)
class AutofillSmsActivity : AppCompatActivity() { class AutofillSmsActivity : AppCompatActivity() {
@ -59,12 +58,13 @@ class AutofillSmsActivity : AppCompatActivity() {
private var fillOtpFromSmsRequestCode = 1 private var fillOtpFromSmsRequestCode = 1
fun shouldOfferFillFromSms(context: Context): Boolean { fun shouldOfferFillFromSms(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false
return false
val googleApiAvailabilityInstance = GoogleApiAvailability.getInstance() val googleApiAvailabilityInstance = GoogleApiAvailability.getInstance()
val googleApiStatus = googleApiAvailabilityInstance.isGooglePlayServicesAvailable(context) val googleApiStatus = googleApiAvailabilityInstance.isGooglePlayServicesAvailable(context)
if (googleApiStatus != ConnectionResult.SUCCESS) { if (googleApiStatus != ConnectionResult.SUCCESS) {
w { "Google Play Services unavailable or not updated: ${googleApiAvailabilityInstance.getErrorString(googleApiStatus)}" } w {
"Google Play Services unavailable or not updated: ${googleApiAvailabilityInstance.getErrorString(googleApiStatus)}"
}
return false return false
} }
// https://developer.android.com/guide/topics/text/autofill-services#sms-autofill // https://developer.android.com/guide/topics/text/autofill-services#sms-autofill
@ -77,12 +77,8 @@ class AutofillSmsActivity : AppCompatActivity() {
fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender { fun makeFillOtpFromSmsIntentSender(context: Context): IntentSender {
val intent = Intent(context, AutofillSmsActivity::class.java) val intent = Intent(context, AutofillSmsActivity::class.java)
return PendingIntent.getActivity( return PendingIntent.getActivity(context, fillOtpFromSmsRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
context, .intentSender
fillOtpFromSmsRequestCode++,
intent,
PendingIntent.FLAG_CANCEL_CURRENT
).intentSender
} }
} }
@ -94,66 +90,61 @@ class AutofillSmsActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
setResult(RESULT_CANCELED) setResult(RESULT_CANCELED)
binding.cancelButton.setOnClickListener { binding.cancelButton.setOnClickListener { finish() }
finish()
}
} }
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { clientState =
intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE)
?: run {
e { "AutofillSmsActivity started without EXTRA_CLIENT_STATE" } e { "AutofillSmsActivity started without EXTRA_CLIENT_STATE" }
finish() finish()
return return
} }
registerReceiver(smsCodeRetrievedReceiver, IntentFilter(SmsCodeRetriever.SMS_CODE_RETRIEVED_ACTION), SmsRetriever.SEND_PERMISSION, null) registerReceiver(
lifecycleScope.launch { smsCodeRetrievedReceiver,
waitForSms() IntentFilter(SmsCodeRetriever.SMS_CODE_RETRIEVED_ACTION),
} SmsRetriever.SEND_PERMISSION,
null
)
lifecycleScope.launch { waitForSms() }
} }
// Retry starting the SMS code retriever after a permission request. // Retry starting the SMS code retriever after a permission request.
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
if (resultCode != Activity.RESULT_OK) if (resultCode != Activity.RESULT_OK) return
return lifecycleScope.launch { waitForSms() }
lifecycleScope.launch {
waitForSms()
}
} }
private suspend fun waitForSms() { private suspend fun waitForSms() {
val smsClient = SmsCodeRetriever.getAutofillClient(this@AutofillSmsActivity) val smsClient = SmsCodeRetriever.getAutofillClient(this@AutofillSmsActivity)
runCatching { runCatching { withContext(Dispatchers.IO) { smsClient.startSmsCodeRetriever().suspendableAwait() } }.onFailure { e
withContext(Dispatchers.IO) { ->
smsClient.startSmsCodeRetriever().suspendableAwait()
}
}.onFailure { e ->
if (e is ResolvableApiException) { if (e is ResolvableApiException) {
e.startResolutionForResult(this@AutofillSmsActivity, 1) e.startResolutionForResult(this@AutofillSmsActivity, 1)
} else { } else {
e(e) e(e)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) { finish() }
finish()
}
} }
} }
} }
private val smsCodeRetrievedReceiver = object : BroadcastReceiver() { private val smsCodeRetrievedReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val smsCode = intent.getStringExtra(SmsCodeRetriever.EXTRA_SMS_CODE) val smsCode = intent.getStringExtra(SmsCodeRetriever.EXTRA_SMS_CODE)
val fillInDataset = AutofillResponseBuilder.makeFillInDataset( val fillInDataset =
AutofillResponseBuilder.makeFillInDataset(
this@AutofillSmsActivity, this@AutofillSmsActivity,
Credentials(null, null, smsCode), Credentials(null, null, smsCode),
clientState, clientState,
AutofillAction.FillOtpFromSms AutofillAction.FillOtpFromSms
) )
setResult(RESULT_OK, Intent().apply { setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) })
putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset)
})
finish() finish()
} }
} }

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