all: reformat with ktfmt
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
parent
be31ae37f4
commit
774fda83ac
145 changed files with 12016 additions and 12490 deletions
|
@ -4,7 +4,7 @@
|
||||||
<option name="LINE_SEPARATOR" value=" " />
|
<option name="LINE_SEPARATOR" value=" " />
|
||||||
<option name="WRAP_WHEN_TYPING_REACHES_RIGHT_MARGIN" value="true" />
|
<option name="WRAP_WHEN_TYPING_REACHES_RIGHT_MARGIN" value="true" />
|
||||||
<option name="FORMATTER_TAGS_ENABLED" value="true" />
|
<option name="FORMATTER_TAGS_ENABLED" value="true" />
|
||||||
<option name="SOFT_MARGINS" value="100" />
|
<option name="SOFT_MARGINS" value="120" />
|
||||||
<option name="DO_NOT_FORMAT">
|
<option name="DO_NOT_FORMAT">
|
||||||
<list>
|
<list>
|
||||||
<fileSet type="namedScope" name="third_party" pattern="src[Android-Password-Store.app]:mozilla.components.lib.publicsuffixlist..*" />
|
<fileSet type="namedScope" name="third_party" pattern="src[Android-Password-Store.app]:mozilla.components.lib.publicsuffixlist..*" />
|
||||||
|
@ -161,7 +161,7 @@
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="kotlin">
|
<codeStyleSettings language="kotlin">
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||||
<option name="RIGHT_MARGIN" value="100" />
|
<option name="RIGHT_MARGIN" value="120" />
|
||||||
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="0" />
|
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="0" />
|
||||||
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
|
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
|
||||||
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
|
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
|
||||||
|
|
|
@ -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") {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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:",
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}" }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 ->
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()))
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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" }
|
||||||
|
|
|
@ -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)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() }
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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" }
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
||||||
},
|
},
|
||||||
|
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()))
|
||||||
|
|
|
@ -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
|
|
||||||
)
|
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
Loading…
Reference in a new issue