all: reformat with Spotless again
Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
parent
2fcb285e27
commit
7e2eb2425e
108 changed files with 1385 additions and 473 deletions
|
@ -2,4 +2,4 @@
|
||||||
<state>
|
<state>
|
||||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
</state>
|
</state>
|
||||||
</component>
|
</component>
|
||||||
|
|
|
@ -3,4 +3,4 @@
|
||||||
<option name="notice" value="Copyright © 2014-&#36;today.year The Android Password Store Authors. All Rights Reserved. SPDX-License-Identifier: GPL-3.0-only" />
|
<option name="notice" value="Copyright © 2014-&#36;today.year The Android Password Store Authors. All Rights Reserved. SPDX-License-Identifier: GPL-3.0-only" />
|
||||||
<option name="myName" value="APS" />
|
<option name="myName" value="APS" />
|
||||||
</copyright>
|
</copyright>
|
||||||
</component>
|
</component>
|
||||||
|
|
|
@ -22,4 +22,4 @@
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
|
@ -37,4 +37,4 @@
|
||||||
<option name="url" value="file:$USER_HOME$/.m2/repository/" />
|
<option name="url" value="file:$USER_HOME$/.m2/repository/" />
|
||||||
</remote-repository>
|
</remote-repository>
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
|
@ -3,4 +3,4 @@
|
||||||
<component name="KotlinScriptingSettings">
|
<component name="KotlinScriptingSettings">
|
||||||
<option name="suppressDefinitionsCheck" value="true" />
|
<option name="suppressDefinitionsCheck" value="true" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
<component name="DependencyValidationManager">
|
<component name="DependencyValidationManager">
|
||||||
<scope name="third_party" pattern="src[Android-Password-Store.app]:mozilla.components.lib.publicsuffixlist..*" />
|
<scope name="third_party" pattern="src[Android-Password-Store.app]:mozilla.components.lib.publicsuffixlist..*" />
|
||||||
</component>
|
</component>
|
||||||
|
|
|
@ -6,4 +6,4 @@
|
||||||
<issue id="InvalidPackage">
|
<issue id="InvalidPackage">
|
||||||
<ignore regexp="X509LDAPCertStoreSpi" />
|
<ignore regexp="X509LDAPCertStoreSpi" />
|
||||||
</issue>
|
</issue>
|
||||||
</lint>
|
</lint>
|
||||||
|
|
|
@ -115,7 +115,10 @@ class MigrationsTest {
|
||||||
putBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, true)
|
putBoolean(PreferenceKeys.CLEAR_CLIPBOARD_20X, true)
|
||||||
}
|
}
|
||||||
runMigrations(context)
|
runMigrations(context)
|
||||||
assertEquals(true, context.sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false))
|
assertEquals(
|
||||||
|
true,
|
||||||
|
context.sharedPrefs.getBoolean(PreferenceKeys.CLEAR_CLIPBOARD_HISTORY, false)
|
||||||
|
)
|
||||||
assertFalse(context.sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X))
|
assertFalse(context.sharedPrefs.contains(PreferenceKeys.CLEAR_CLIPBOARD_20X))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,7 +32,9 @@ 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 || prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)) {
|
if (BuildConfig.ENABLE_DEBUG_FEATURES ||
|
||||||
|
prefs.getBoolean(PreferenceKeys.ENABLE_DEBUG_LOGGING, false)
|
||||||
|
) {
|
||||||
plant(DebugTree())
|
plant(DebugTree())
|
||||||
StrictMode.setVmPolicy(VmPolicy.Builder().detectAll().penaltyLog().build())
|
StrictMode.setVmPolicy(VmPolicy.Builder().detectAll().penaltyLog().build())
|
||||||
StrictMode.setThreadPolicy(ThreadPolicy.Builder().detectAll().penaltyLog().build())
|
StrictMode.setThreadPolicy(ThreadPolicy.Builder().detectAll().penaltyLog().build())
|
||||||
|
|
|
@ -199,8 +199,11 @@ 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 }) ?: emptyArray()).toList()
|
val directories =
|
||||||
val files = (path.listFiles(FileFilter { pathname -> pathname.extension == "gpg" }) ?: emptyArray()).toList()
|
(path.listFiles(FileFilter { pathname -> pathname.isDirectory }) ?: 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)
|
||||||
|
@ -216,7 +219,11 @@ object PasswordRepository {
|
||||||
* @return a list of password items
|
* @return a list of password items
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getPasswords(path: File, rootDir: File, sortOrder: PasswordSortOrder): ArrayList<PasswordItem> {
|
fun getPasswords(
|
||||||
|
path: File,
|
||||||
|
rootDir: File,
|
||||||
|
sortOrder: PasswordSortOrder
|
||||||
|
): ArrayList<PasswordItem> {
|
||||||
// We need to recover the passwords then parse the files
|
// We need to recover the passwords then parse the files
|
||||||
val passList = getFilesList(path).also { it.sortBy { f -> f.name } }
|
val passList = getFilesList(path).also { it.sortBy { f -> f.name } }
|
||||||
val passwordList = ArrayList<PasswordItem>()
|
val passwordList = ArrayList<PasswordItem>()
|
||||||
|
|
|
@ -55,7 +55,8 @@ class FieldItemAdapter(
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) : RecyclerView.ViewHolder(itemView) {
|
class FieldItemViewHolder(itemView: View, val binding: ItemFieldBinding) :
|
||||||
|
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) {
|
||||||
|
@ -66,7 +67,8 @@ class FieldItemAdapter(
|
||||||
when (fieldItem.action) {
|
when (fieldItem.action) {
|
||||||
FieldItem.ActionType.COPY -> {
|
FieldItem.ActionType.COPY -> {
|
||||||
itemTextContainer.apply {
|
itemTextContainer.apply {
|
||||||
endIconDrawable = ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
|
endIconDrawable =
|
||||||
|
ContextCompat.getDrawable(itemView.context, R.drawable.ic_content_copy)
|
||||||
endIconMode = TextInputLayout.END_ICON_CUSTOM
|
endIconMode = TextInputLayout.END_ICON_CUSTOM
|
||||||
setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
setEndIconOnClickListener { copyTextToClipBoard(itemText.text.toString()) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,9 @@ open class PasswordItemRecyclerAdapter :
|
||||||
return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
|
return super.onItemClicked(listener) as PasswordItemRecyclerAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): PasswordItemRecyclerAdapter {
|
override fun onSelectionChanged(
|
||||||
|
listener: (selection: Selection<String>) -> Unit
|
||||||
|
): PasswordItemRecyclerAdapter {
|
||||||
return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter
|
return super.onSelectionChanged(listener) as PasswordItemRecyclerAdapter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,7 +61,8 @@ 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 ?: 0
|
val count =
|
||||||
|
item.file.listFiles { path -> path.isDirectory || path.extension == "gpg" }?.size ?: 0
|
||||||
childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
|
childCount.visibility = if (count > 0) View.VISIBLE else View.GONE
|
||||||
childCount.text = "$count"
|
childCount.text = "$count"
|
||||||
} else {
|
} else {
|
||||||
|
@ -74,7 +77,8 @@ open class PasswordItemRecyclerAdapter :
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup<String>() {
|
class PasswordItemDetailsLookup(private val recyclerView: RecyclerView) :
|
||||||
|
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
|
||||||
|
|
|
@ -73,7 +73,12 @@ class AutofillDecryptActivity : AppCompatActivity() {
|
||||||
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(context, decryptFileRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
decryptFileRequestCode++,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_CANCEL_CURRENT
|
||||||
|
)
|
||||||
.intentSender
|
.intentSender
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -124,9 +129,17 @@ class AutofillDecryptActivity : AppCompatActivity() {
|
||||||
setResult(RESULT_CANCELED)
|
setResult(RESULT_CANCELED)
|
||||||
} else {
|
} else {
|
||||||
val fillInDataset =
|
val fillInDataset =
|
||||||
AutofillResponseBuilder.makeFillInDataset(this@AutofillDecryptActivity, credentials, clientState, action)
|
AutofillResponseBuilder.makeFillInDataset(
|
||||||
|
this@AutofillDecryptActivity,
|
||||||
|
credentials,
|
||||||
|
clientState,
|
||||||
|
action
|
||||||
|
)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) })
|
setResult(
|
||||||
|
RESULT_OK,
|
||||||
|
Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) { finish() }
|
withContext(Dispatchers.Main) { finish() }
|
||||||
|
@ -137,7 +150,11 @@ class AutofillDecryptActivity : AppCompatActivity() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun executeOpenPgpApi(data: Intent, input: InputStream, output: OutputStream): Intent? {
|
private suspend fun executeOpenPgpApi(
|
||||||
|
data: Intent,
|
||||||
|
input: InputStream,
|
||||||
|
output: OutputStream
|
||||||
|
): Intent? {
|
||||||
var openPgpServiceConnection: OpenPgpServiceConnection? = null
|
var openPgpServiceConnection: OpenPgpServiceConnection? = null
|
||||||
val openPgpService =
|
val openPgpService =
|
||||||
suspendCoroutine<IOpenPgpService2> { cont ->
|
suspendCoroutine<IOpenPgpService2> { cont ->
|
||||||
|
@ -177,7 +194,9 @@ class AutofillDecryptActivity : AppCompatActivity() {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
.onSuccess { result ->
|
.onSuccess { result ->
|
||||||
return when (val resultCode = 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 =
|
val entry =
|
||||||
|
@ -185,7 +204,12 @@ class AutofillDecryptActivity : AppCompatActivity() {
|
||||||
@Suppress("BlockingMethodInNonBlockingContext")
|
@Suppress("BlockingMethodInNonBlockingContext")
|
||||||
passwordEntryFactory.create(lifecycleScope, decryptedOutput.toByteArray())
|
passwordEntryFactory.create(lifecycleScope, decryptedOutput.toByteArray())
|
||||||
}
|
}
|
||||||
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" }
|
||||||
|
@ -193,7 +217,8 @@ class AutofillDecryptActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
OpenPgpApi.RESULT_CODE_USER_INTERACTION_REQUIRED -> {
|
||||||
val pendingIntent: PendingIntent = result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!
|
val pendingIntent: PendingIntent =
|
||||||
|
result.getParcelableExtra(OpenPgpApi.RESULT_INTENT)!!
|
||||||
runCatching {
|
runCatching {
|
||||||
val intentToResume =
|
val intentToResume =
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
@ -215,10 +240,16 @@ class AutofillDecryptActivity : AppCompatActivity() {
|
||||||
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(applicationContext, "Error from OpenKeyChain: ${error.message}", Toast.LENGTH_LONG)
|
Toast.makeText(
|
||||||
|
applicationContext,
|
||||||
|
"Error from OpenKeyChain: ${error.message}",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
e { "OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}" }
|
e {
|
||||||
|
"OpenPgpApi ACTION_DECRYPT_VERIFY failed (${error.errorId}): ${error.message}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,11 +46,16 @@ 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 = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB"
|
private const val EXTRA_FORM_ORIGIN_WEB =
|
||||||
private const val EXTRA_FORM_ORIGIN_APP = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
|
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_WEB"
|
||||||
|
private const val EXTRA_FORM_ORIGIN_APP =
|
||||||
|
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FORM_ORIGIN_APP"
|
||||||
private var matchAndDecryptFileRequestCode = 1
|
private var matchAndDecryptFileRequestCode = 1
|
||||||
|
|
||||||
fun makeMatchAndDecryptFileIntentSender(context: Context, formOrigin: FormOrigin): IntentSender {
|
fun makeMatchAndDecryptFileIntentSender(
|
||||||
|
context: Context,
|
||||||
|
formOrigin: FormOrigin
|
||||||
|
): IntentSender {
|
||||||
val intent =
|
val intent =
|
||||||
Intent(context, AutofillFilterView::class.java).apply {
|
Intent(context, AutofillFilterView::class.java).apply {
|
||||||
when (formOrigin) {
|
when (formOrigin) {
|
||||||
|
@ -108,7 +113,9 @@ class AutofillFilterView : AppCompatActivity() {
|
||||||
FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
|
FormOrigin.App(intent!!.getStringExtra(EXTRA_FORM_ORIGIN_APP)!!)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
e { "AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP" }
|
e {
|
||||||
|
"AutofillFilterActivity started without EXTRA_FORM_ORIGIN_WEB or EXTRA_FORM_ORIGIN_APP"
|
||||||
|
}
|
||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -125,7 +132,8 @@ class AutofillFilterView : AppCompatActivity() {
|
||||||
with(binding) {
|
with(binding) {
|
||||||
rvPassword.apply {
|
rvPassword.apply {
|
||||||
adapter =
|
adapter =
|
||||||
SearchableRepositoryAdapter(R.layout.oreo_autofill_filter_row, ::PasswordViewHolder) { item ->
|
SearchableRepositoryAdapter(R.layout.oreo_autofill_filter_row, ::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)
|
||||||
|
@ -171,10 +179,15 @@ class AutofillFilterView : AppCompatActivity() {
|
||||||
setOnCheckedChangeListener { _, _ -> updateSearch() }
|
setOnCheckedChangeListener { _, _ -> updateSearch() }
|
||||||
}
|
}
|
||||||
shouldMatch.text =
|
shouldMatch.text =
|
||||||
getString(R.string.oreo_autofill_match_with, formOrigin.getPrettyIdentifier(applicationContext))
|
getString(
|
||||||
|
R.string.oreo_autofill_match_with,
|
||||||
|
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.scrollToPosition(0) }
|
(rvPassword.adapter as SearchableRepositoryAdapter).submitList(list) {
|
||||||
|
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) ||
|
||||||
|
@ -189,16 +202,21 @@ class AutofillFilterView : AppCompatActivity() {
|
||||||
private fun updateSearch() {
|
private fun updateSearch() {
|
||||||
model.search(
|
model.search(
|
||||||
binding.search.text.toString().trim(),
|
binding.search.text.toString().trim(),
|
||||||
filterMode = if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy,
|
filterMode =
|
||||||
|
if (binding.strictDomainSearch.isChecked) FilterMode.StrictDomain else FilterMode.Fuzzy,
|
||||||
searchMode = SearchMode.RecursivelyInSubdirectories,
|
searchMode = SearchMode.RecursivelyInSubdirectories,
|
||||||
listMode = ListMode.FilesOnly
|
listMode = ListMode.FilesOnly
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decryptAndFill(item: PasswordItem) {
|
private fun decryptAndFill(item: PasswordItem) {
|
||||||
if (binding.shouldClear.isChecked) AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
|
if (binding.shouldClear.isChecked)
|
||||||
if (binding.shouldMatch.isChecked) AutofillMatcher.addMatchFor(applicationContext, formOrigin, item.file)
|
AutofillMatcher.clearMatchesFor(applicationContext, formOrigin)
|
||||||
|
if (binding.shouldMatch.isChecked)
|
||||||
|
AutofillMatcher.addMatchFor(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(item.file, intent!!.extras!!, this))
|
decryptAction.launch(
|
||||||
|
AutofillDecryptActivity.makeDecryptFileIntent(item.file, intent!!.extras!!, this)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -83,9 +83,15 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
|
||||||
resetButton.visibility = View.VISIBLE
|
resetButton.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
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 { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) })
|
setResult(
|
||||||
|
RESULT_OK,
|
||||||
|
Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillResponse) }
|
||||||
|
)
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -96,13 +102,18 @@ class AutofillPublisherChangedActivity : AppCompatActivity() {
|
||||||
with(binding) {
|
with(binding) {
|
||||||
val packageInfo = packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
|
val packageInfo = packageManager.getPackageInfo(appPackage, PackageManager.GET_META_DATA)
|
||||||
val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
|
val installTime = DateUtils.getRelativeTimeSpanString(packageInfo.firstInstallTime)
|
||||||
warningAppInstallDate.text = getString(R.string.oreo_autofill_warning_publisher_install_time, installTime)
|
warningAppInstallDate.text =
|
||||||
|
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 =
|
warningAppAdvancedInfo.text =
|
||||||
getString(R.string.oreo_autofill_warning_publisher_advanced_info_template, appPackage, currentHash)
|
getString(
|
||||||
|
R.string.oreo_autofill_warning_publisher_advanced_info_template,
|
||||||
|
appPackage,
|
||||||
|
currentHash
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
|
|
|
@ -34,13 +34,20 @@ class AutofillSaveActivity : AppCompatActivity() {
|
||||||
private const val EXTRA_FOLDER_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME"
|
private const val EXTRA_FOLDER_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_FOLDER_NAME"
|
||||||
private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD"
|
private const val EXTRA_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_PASSWORD"
|
||||||
private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME"
|
private const val EXTRA_NAME = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_NAME"
|
||||||
private const val EXTRA_SHOULD_MATCH_APP = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP"
|
private const val EXTRA_SHOULD_MATCH_APP =
|
||||||
private const val EXTRA_SHOULD_MATCH_WEB = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
|
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_APP"
|
||||||
private const val EXTRA_GENERATE_PASSWORD = "dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
|
private const val EXTRA_SHOULD_MATCH_WEB =
|
||||||
|
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_SHOULD_MATCH_WEB"
|
||||||
|
private const val EXTRA_GENERATE_PASSWORD =
|
||||||
|
"dev.msfjarvis.aps.autofill.oreo.ui.EXTRA_GENERATE_PASSWORD"
|
||||||
|
|
||||||
private var saveRequestCode = 1
|
private var saveRequestCode = 1
|
||||||
|
|
||||||
fun makeSaveIntentSender(context: Context, credentials: Credentials?, formOrigin: FormOrigin): IntentSender {
|
fun makeSaveIntentSender(
|
||||||
|
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 =
|
val sanitizedIdentifier =
|
||||||
|
@ -52,7 +59,11 @@ class AutofillSaveActivity : AppCompatActivity() {
|
||||||
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 =
|
val intent =
|
||||||
Intent(context, AutofillSaveActivity::class.java).apply {
|
Intent(context, AutofillSaveActivity::class.java).apply {
|
||||||
putExtras(
|
putExtras(
|
||||||
|
@ -60,13 +71,20 @@ class AutofillSaveActivity : AppCompatActivity() {
|
||||||
EXTRA_FOLDER_NAME to folderName,
|
EXTRA_FOLDER_NAME to folderName,
|
||||||
EXTRA_NAME to fileName,
|
EXTRA_NAME to fileName,
|
||||||
EXTRA_PASSWORD to credentials?.password,
|
EXTRA_PASSWORD to credentials?.password,
|
||||||
EXTRA_SHOULD_MATCH_APP to formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
|
EXTRA_SHOULD_MATCH_APP to
|
||||||
EXTRA_SHOULD_MATCH_WEB to formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
|
formOrigin.identifier.takeIf { formOrigin is FormOrigin.App },
|
||||||
|
EXTRA_SHOULD_MATCH_WEB to
|
||||||
|
formOrigin.identifier.takeIf { formOrigin is FormOrigin.Web },
|
||||||
EXTRA_GENERATE_PASSWORD to (credentials == null)
|
EXTRA_GENERATE_PASSWORD to (credentials == null)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return PendingIntent.getActivity(context, saveRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
saveRequestCode++,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_CANCEL_CURRENT
|
||||||
|
)
|
||||||
.intentSender
|
.intentSender
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -94,7 +112,8 @@ class AutofillSaveActivity : AppCompatActivity() {
|
||||||
"FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
|
"FILE_PATH" to repo.resolve(intent.getStringExtra(EXTRA_FOLDER_NAME)!!).absolutePath,
|
||||||
PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
|
PasswordCreationActivity.EXTRA_FILE_NAME to intent.getStringExtra(EXTRA_NAME),
|
||||||
PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
|
PasswordCreationActivity.EXTRA_PASSWORD to intent.getStringExtra(EXTRA_PASSWORD),
|
||||||
PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
|
PasswordCreationActivity.EXTRA_GENERATE_PASSWORD to
|
||||||
|
intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -117,8 +136,15 @@ class AutofillSaveActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
val credentials = Credentials(username, password, null)
|
val credentials = Credentials(username, password, null)
|
||||||
val fillInDataset =
|
val fillInDataset =
|
||||||
AutofillResponseBuilder.makeFillInDataset(this, credentials, clientState, AutofillAction.Generate)
|
AutofillResponseBuilder.makeFillInDataset(
|
||||||
Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }
|
this,
|
||||||
|
credentials,
|
||||||
|
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()
|
||||||
|
|
|
@ -157,7 +157,10 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
previousListener = null
|
previousListener = null
|
||||||
serviceConnection = OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also { it.bindToService() }
|
serviceConnection =
|
||||||
|
OpenPgpServiceConnection(this, OPENPGP_PROVIDER, onBoundListener).also {
|
||||||
|
it.bindToService()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -250,7 +253,10 @@ open class BasePgpActivity : AppCompatActivity(), OpenPgpServiceConnection.OnBou
|
||||||
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 */
|
||||||
|
|
|
@ -44,7 +44,9 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
private val binding by viewBinding(DecryptLayoutBinding::inflate)
|
private val binding by viewBinding(DecryptLayoutBinding::inflate)
|
||||||
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
||||||
|
|
||||||
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 =
|
private val userInteractionRequiredResult =
|
||||||
|
@ -136,7 +138,10 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
intent.putExtra("REPO_PATH", repoPath)
|
intent.putExtra("REPO_PATH", repoPath)
|
||||||
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
|
intent.putExtra(PasswordCreationActivity.EXTRA_FILE_NAME, name)
|
||||||
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
|
intent.putExtra(PasswordCreationActivity.EXTRA_PASSWORD, passwordEntry?.password)
|
||||||
intent.putExtra(PasswordCreationActivity.EXTRA_EXTRA_CONTENT, passwordEntry?.extraContentWithoutAuthData)
|
intent.putExtra(
|
||||||
|
PasswordCreationActivity.EXTRA_EXTRA_CONTENT,
|
||||||
|
passwordEntry?.extraContentWithoutAuthData
|
||||||
|
)
|
||||||
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
|
intent.putExtra(PasswordCreationActivity.EXTRA_EDITING, true)
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
finish()
|
finish()
|
||||||
|
@ -150,7 +155,9 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
type = "text/plain"
|
type = "text/plain"
|
||||||
}
|
}
|
||||||
// Always show a picker to give the user a chance to cancel
|
// Always show a picker to give the user a chance to cancel
|
||||||
startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to)))
|
startActivity(
|
||||||
|
Intent.createChooser(sendIntent, resources.getText(R.string.send_plaintext_password_to))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTime::class)
|
@OptIn(ExperimentalTime::class)
|
||||||
|
@ -166,7 +173,10 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
val outputStream = ByteArrayOutputStream()
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
val result = withContext(Dispatchers.IO) { checkNotNull(api).executeApi(data, inputStream, outputStream) }
|
val result =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
checkNotNull(api).executeApi(data, inputStream, outputStream)
|
||||||
|
}
|
||||||
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 -> {
|
||||||
startAutoDismissTimer()
|
startAutoDismissTimer()
|
||||||
|
@ -174,7 +184,8 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
|
val showPassword = settings.getBoolean(PreferenceKeys.SHOW_PASSWORD, true)
|
||||||
val entry = passwordEntryFactory.create(lifecycleScope, outputStream.toByteArray())
|
val entry = passwordEntryFactory.create(lifecycleScope, outputStream.toByteArray())
|
||||||
val items = arrayListOf<FieldItem>()
|
val items = arrayListOf<FieldItem>()
|
||||||
val adapter = FieldItemAdapter(emptyList(), showPassword) { text -> copyTextToClipboard(text) }
|
val adapter =
|
||||||
|
FieldItemAdapter(emptyList(), showPassword) { 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)
|
||||||
|
@ -190,7 +201,9 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
if (entry.hasTotp()) {
|
if (entry.hasTotp()) {
|
||||||
launch {
|
launch {
|
||||||
items.add(FieldItem.createOtpField(entry.totp.value))
|
items.add(FieldItem.createOtpField(entry.totp.value))
|
||||||
entry.totp.collect { code -> withContext(Dispatchers.Main) { adapter.updateOTPCode(code) } }
|
entry.totp.collect { code ->
|
||||||
|
withContext(Dispatchers.Main) { adapter.updateOTPCode(code) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -198,7 +211,9 @@ class DecryptActivity : BasePgpActivity(), OpenPgpServiceConnection.OnBound {
|
||||||
items.add(FieldItem.createUsernameField(entry.username!!))
|
items.add(FieldItem.createUsernameField(entry.username!!))
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.extraContent.forEach { (key, value) -> items.add(FieldItem(key, value, FieldItem.ActionType.COPY)) }
|
entry.extraContent.forEach { (key, value) ->
|
||||||
|
items.add(FieldItem(key, value, FieldItem.ActionType.COPY))
|
||||||
|
}
|
||||||
|
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
adapter.updateItems(items)
|
adapter.updateItems(items)
|
||||||
|
|
|
@ -55,7 +55,9 @@ class GetKeyIdsActivity : BasePgpActivity() {
|
||||||
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
OpenPgpApi.RESULT_CODE_SUCCESS -> {
|
||||||
runCatching {
|
runCatching {
|
||||||
val ids =
|
val ids =
|
||||||
result.getLongArrayExtra(OpenPgpApi.RESULT_KEY_IDS)?.map { 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)
|
||||||
|
|
|
@ -63,14 +63,24 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
|
private val binding by viewBinding(PasswordCreationActivityBinding::inflate)
|
||||||
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
@Inject lateinit var passwordEntryFactory: PasswordEntryFactory
|
||||||
|
|
||||||
private val suggestedName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
private val suggestedName by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_PASSWORD) }
|
intent.getStringExtra(EXTRA_FILE_NAME)
|
||||||
private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_EXTRA_CONTENT) }
|
}
|
||||||
|
private val suggestedPass by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
|
intent.getStringExtra(EXTRA_PASSWORD)
|
||||||
|
}
|
||||||
|
private val suggestedExtra by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
|
intent.getStringExtra(EXTRA_EXTRA_CONTENT)
|
||||||
|
}
|
||||||
private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) {
|
private val shouldGeneratePassword by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
intent.getBooleanExtra(EXTRA_GENERATE_PASSWORD, false)
|
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) {
|
||||||
private val oldFileName by lazy(LazyThreadSafetyMode.NONE) { intent.getStringExtra(EXTRA_FILE_NAME) }
|
intent.getBooleanExtra(EXTRA_EDITING, false)
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
@ -99,7 +109,8 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
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') binding.extraContent.append("\n$contents")
|
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
|
||||||
|
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 {
|
||||||
|
@ -113,18 +124,27 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
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) { gpgIdentifierFile.writeText((keyIds + "").joinToString("\n")) }
|
withContext(Dispatchers.IO) {
|
||||||
|
gpgIdentifierFile.writeText((keyIds + "").joinToString("\n"))
|
||||||
|
}
|
||||||
commitChange(
|
commitChange(
|
||||||
getString(
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
snackbar(message = getString(R.string.gpg_key_select_mandatory), length = Snackbar.LENGTH_LONG)
|
snackbar(
|
||||||
|
message = getString(R.string.gpg_key_select_mandatory),
|
||||||
|
length = Snackbar.LENGTH_LONG
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,24 +168,31 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
bindToOpenKeychain(this)
|
bindToOpenKeychain(this)
|
||||||
title = if (editing) getString(R.string.edit_password) else getString(R.string.new_password_title)
|
title =
|
||||||
|
if (editing) 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) {
|
supportFragmentManager.setFragmentResultListener(
|
||||||
requestKey,
|
OTP_RESULT_REQUEST_KEY,
|
||||||
bundle ->
|
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') binding.extraContent.append("\n$contents")
|
if (currentExtras.isNotEmpty() && currentExtras.last() != '\n')
|
||||||
|
binding.extraContent.append("\n$contents")
|
||||||
else binding.extraContent.append(contents)
|
else binding.extraContent.append(contents)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true
|
val hasCamera = packageManager?.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) == true
|
||||||
if (hasCamera) {
|
if (hasCamera) {
|
||||||
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 ->
|
||||||
when (index) {
|
when (index) {
|
||||||
|
@ -209,7 +236,8 @@ 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) == DirectoryStructure.FileBased
|
AutofillPreferences.directoryStructure(this@PasswordCreationActivity) ==
|
||||||
|
DirectoryStructure.FileBased
|
||||||
) {
|
) {
|
||||||
encryptUsername.apply {
|
encryptUsername.apply {
|
||||||
visibility = View.VISIBLE
|
visibility = View.VISIBLE
|
||||||
|
@ -226,7 +254,10 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
// User wants to disable username encryption, so we extract the
|
// User wants to disable username encryption, so we extract the
|
||||||
// username from the encrypted extras and use it as the filename.
|
// username from the encrypted extras and use it as the filename.
|
||||||
val entry =
|
val entry =
|
||||||
passwordEntryFactory.create(lifecycleScope, "PASSWORD\n${extraContent.text}".encodeToByteArray())
|
passwordEntryFactory.create(
|
||||||
|
lifecycleScope,
|
||||||
|
"PASSWORD\n${extraContent.text}".encodeToByteArray()
|
||||||
|
)
|
||||||
val username = entry.username
|
val username = entry.username
|
||||||
|
|
||||||
// username should not be null here by the logic in
|
// username should not be null here by the logic in
|
||||||
|
@ -239,7 +270,9 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
listOf(filename, extraContent).forEach { it.doOnTextChanged { _, _, _, _ -> updateViewState() } }
|
listOf(filename, extraContent).forEach {
|
||||||
|
it.doOnTextChanged { _, _, _, _ -> updateViewState() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
suggestedPass?.let {
|
suggestedPass?.let {
|
||||||
password.setText(it)
|
password.setText(it)
|
||||||
|
@ -279,21 +312,29 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generatePassword() {
|
private fun generatePassword() {
|
||||||
supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) { requestKey, bundle ->
|
supportFragmentManager.setFragmentResultListener(PASSWORD_RESULT_REQUEST_KEY, this) {
|
||||||
|
requestKey,
|
||||||
|
bundle ->
|
||||||
if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
|
if (requestKey == PASSWORD_RESULT_REQUEST_KEY) {
|
||||||
binding.password.setText(bundle.getString(RESULT))
|
binding.password.setText(bundle.getString(RESULT))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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().show(supportFragmentManager, "generator")
|
KEY_PWGEN_TYPE_CLASSIC ->
|
||||||
KEY_PWGEN_TYPE_XKPASSWD -> XkPasswordGeneratorDialogFragment().show(supportFragmentManager, "xkpwgenerator")
|
PasswordGeneratorDialogFragment().show(supportFragmentManager, "generator")
|
||||||
|
KEY_PWGEN_TYPE_XKPASSWD ->
|
||||||
|
XkPasswordGeneratorDialogFragment().show(supportFragmentManager, "xkpwgenerator")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateViewState() =
|
private fun updateViewState() =
|
||||||
with(binding) {
|
with(binding) {
|
||||||
// Use PasswordEntry to parse extras for username
|
// Use PasswordEntry to parse extras for username
|
||||||
val entry = passwordEntryFactory.create(lifecycleScope, "PLACEHOLDER\n${extraContent.text}".encodeToByteArray())
|
val entry =
|
||||||
|
passwordEntryFactory.create(
|
||||||
|
lifecycleScope,
|
||||||
|
"PLACEHOLDER\n${extraContent.text}".encodeToByteArray()
|
||||||
|
)
|
||||||
encryptUsername.apply {
|
encryptUsername.apply {
|
||||||
if (visibility != View.VISIBLE) return@apply
|
if (visibility != View.VISIBLE) return@apply
|
||||||
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
val hasUsernameInFileName = filename.text.toString().isNotBlank()
|
||||||
|
@ -354,14 +395,18 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (gpgIdentifiers.isEmpty()) {
|
if (gpgIdentifiers.isEmpty()) {
|
||||||
gpgKeySelectAction.launch(Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java))
|
gpgKeySelectAction.launch(
|
||||||
|
Intent(this@PasswordCreationActivity, GetKeyIdsActivity::class.java)
|
||||||
|
)
|
||||||
return@with
|
return@with
|
||||||
}
|
}
|
||||||
val keyIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.KeyId>().map { it.id }.toLongArray()
|
val keyIds =
|
||||||
|
gpgIdentifiers.filterIsInstance<GpgIdentifier.KeyId>().map { it.id }.toLongArray()
|
||||||
if (keyIds.isNotEmpty()) {
|
if (keyIds.isNotEmpty()) {
|
||||||
encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds)
|
encryptionIntent.putExtra(OpenPgpApi.EXTRA_KEY_IDS, keyIds)
|
||||||
}
|
}
|
||||||
val userIds = gpgIdentifiers.filterIsInstance<GpgIdentifier.UserId>().map { it.email }.toTypedArray()
|
val userIds =
|
||||||
|
gpgIdentifiers.filterIsInstance<GpgIdentifier.UserId>().map { it.email }.toTypedArray()
|
||||||
if (userIds.isNotEmpty()) {
|
if (userIds.isNotEmpty()) {
|
||||||
encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds)
|
encryptionIntent.putExtra(OpenPgpApi.EXTRA_USER_IDS, userIds)
|
||||||
}
|
}
|
||||||
|
@ -396,7 +441,9 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.Main) {
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.IO) { checkNotNull(api).executeApi(encryptionIntent, inputStream, outputStream) }
|
withContext(Dispatchers.IO) {
|
||||||
|
checkNotNull(api).executeApi(encryptionIntent, inputStream, outputStream)
|
||||||
|
}
|
||||||
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 {
|
||||||
|
@ -405,7 +452,9 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
// Additionally, if we were editing and the incoming and outgoing
|
// Additionally, if we were editing and the incoming and outgoing
|
||||||
// filenames differ, it means we renamed. Ensure that the target
|
// filenames differ, it means we renamed. Ensure that the target
|
||||||
// doesn't already exist to prevent an accidental overwrite.
|
// doesn't already exist to prevent an accidental overwrite.
|
||||||
if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) && file.exists()) {
|
if ((!editing || (editing && suggestedName != file.nameWithoutExtension)) &&
|
||||||
|
file.exists()
|
||||||
|
) {
|
||||||
snackbar(message = getString(R.string.password_creation_duplicate_error))
|
snackbar(message = getString(R.string.password_creation_duplicate_error))
|
||||||
return@runCatching
|
return@runCatching
|
||||||
}
|
}
|
||||||
|
@ -415,7 +464,9 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
return@runCatching
|
return@runCatching
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.IO) { file.outputStream().use { it.write(outputStream.toByteArray()) } }
|
withContext(Dispatchers.IO) {
|
||||||
|
file.outputStream().use { it.write(outputStream.toByteArray()) }
|
||||||
|
}
|
||||||
|
|
||||||
// associate the new password name with the last name's timestamp in
|
// associate the new password name with the last name's timestamp in
|
||||||
// history
|
// history
|
||||||
|
@ -432,7 +483,10 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
val returnIntent = Intent()
|
val returnIntent = Intent()
|
||||||
returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
|
returnIntent.putExtra(RETURN_EXTRA_CREATED_FILE, path)
|
||||||
returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
|
returnIntent.putExtra(RETURN_EXTRA_NAME, editName)
|
||||||
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 = AutofillPreferences.directoryStructure(applicationContext)
|
val directoryStructure = AutofillPreferences.directoryStructure(applicationContext)
|
||||||
|
@ -442,13 +496,18 @@ class PasswordCreationActivity : BasePgpActivity(), OpenPgpServiceConnection.OnB
|
||||||
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
|
returnIntent.putExtra(RETURN_EXTRA_USERNAME, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (directoryInputLayout.isVisible && directoryInputLayout.isEnabled && oldFileName != null) {
|
if (directoryInputLayout.isVisible &&
|
||||||
|
directoryInputLayout.isEnabled &&
|
||||||
|
oldFileName != null
|
||||||
|
) {
|
||||||
val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg")
|
val oldFile = File("$repoPath/${oldCategory?.trim('/')}/$oldFileName.gpg")
|
||||||
if (oldFile.path != file.path && !oldFile.delete()) {
|
if (oldFile.path != file.path && !oldFile.delete()) {
|
||||||
setResult(RESULT_CANCELED)
|
setResult(RESULT_CANCELED)
|
||||||
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
MaterialAlertDialogBuilder(this@PasswordCreationActivity)
|
||||||
.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) { _, _ -> finish() }
|
.setPositiveButton(android.R.string.ok) { _, _ -> finish() }
|
||||||
.show()
|
.show()
|
||||||
|
@ -456,9 +515,12 @@ 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(commitMessageRes, getLongName(fullPath, repoPath, editName)))
|
commitChange(
|
||||||
|
resources.getString(commitMessageRes, getLongName(fullPath, repoPath, editName))
|
||||||
|
)
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
setResult(RESULT_OK, returnIntent)
|
setResult(RESULT_OK, returnIntent)
|
||||||
finish()
|
finish()
|
||||||
|
|
|
@ -52,7 +52,11 @@ private constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
if (savedInstanceState != null) dismiss()
|
if (savedInstanceState != null) dismiss()
|
||||||
return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
|
return layoutInflater.inflate(R.layout.basic_bottom_sheet, container, false)
|
||||||
}
|
}
|
||||||
|
@ -85,7 +89,9 @@ private constructor(
|
||||||
}
|
}
|
||||||
if (negativeButtonClickListener != null) {
|
if (negativeButtonClickListener != null) {
|
||||||
binding.bottomSheetCancelButton.isVisible = true
|
binding.bottomSheetCancelButton.isVisible = true
|
||||||
negativeButtonLabel?.let { buttonLbl -> binding.bottomSheetCancelButton.text = buttonLbl }
|
negativeButtonLabel?.let { buttonLbl ->
|
||||||
|
binding.bottomSheetCancelButton.text = buttonLbl
|
||||||
|
}
|
||||||
binding.bottomSheetCancelButton.setOnClickListener {
|
binding.bottomSheetCancelButton.setOnClickListener {
|
||||||
negativeButtonClickListener.onClick(it)
|
negativeButtonClickListener.onClick(it)
|
||||||
dismiss()
|
dismiss()
|
||||||
|
@ -95,7 +101,9 @@ private constructor(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
val gradientDrawable =
|
val gradientDrawable =
|
||||||
GradientDrawable().apply { setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) }
|
GradientDrawable().apply {
|
||||||
|
setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
|
||||||
|
}
|
||||||
view.background = gradientDrawable
|
view.background = gradientDrawable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -133,13 +141,19 @@ private constructor(
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPositiveButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
|
fun setPositiveButtonClickListener(
|
||||||
|
buttonLabel: String? = null,
|
||||||
|
listener: View.OnClickListener
|
||||||
|
): Builder {
|
||||||
this.positiveButtonClickListener = listener
|
this.positiveButtonClickListener = listener
|
||||||
this.positiveButtonLabel = buttonLabel
|
this.positiveButtonLabel = buttonLabel
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setNegativeButtonClickListener(buttonLabel: String? = null, listener: View.OnClickListener): Builder {
|
fun setNegativeButtonClickListener(
|
||||||
|
buttonLabel: String? = null,
|
||||||
|
listener: View.OnClickListener
|
||||||
|
): Builder {
|
||||||
this.negativeButtonClickListener = listener
|
this.negativeButtonClickListener = listener
|
||||||
this.negativeButtonLabel = buttonLabel
|
this.negativeButtonLabel = buttonLabel
|
||||||
return this
|
return this
|
||||||
|
|
|
@ -37,7 +37,11 @@ class ItemCreationBottomSheet : BottomSheetDialogFragment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
if (savedInstanceState != null) dismiss()
|
if (savedInstanceState != null) dismiss()
|
||||||
return inflater.inflate(R.layout.item_create_sheet, container, false)
|
return inflater.inflate(R.layout.item_create_sheet, container, false)
|
||||||
}
|
}
|
||||||
|
@ -67,7 +71,9 @@ class ItemCreationBottomSheet : BottomSheetDialogFragment() {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
val gradientDrawable =
|
val gradientDrawable =
|
||||||
GradientDrawable().apply { setColor(requireContext().resolveAttribute(android.R.attr.windowBackground)) }
|
GradientDrawable().apply {
|
||||||
|
setColor(requireContext().resolveAttribute(android.R.attr.windowBackground))
|
||||||
|
}
|
||||||
view.background = gradientDrawable
|
view.background = gradientDrawable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,10 @@ 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.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
val prefs =
|
||||||
|
requireActivity()
|
||||||
|
.applicationContext
|
||||||
|
.getSharedPreferences("PasswordGenerator", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
builder.setView(binding.root)
|
builder.setView(binding.root)
|
||||||
|
|
||||||
|
@ -65,7 +68,9 @@ class PasswordGeneratorDialogFragment : DialogFragment() {
|
||||||
.apply {
|
.apply {
|
||||||
setOnShowListener {
|
setOnShowListener {
|
||||||
generate(binding.passwordText)
|
generate(binding.passwordText)
|
||||||
getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener { generate(binding.passwordText) }
|
getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener {
|
||||||
|
generate(binding.passwordText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,7 +49,9 @@ class XkPasswordGeneratorDialogFragment : DialogFragment() {
|
||||||
binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS))
|
binding.xkNumWords.setText(prefs.getString(PREF_KEY_NUM_WORDS, DEFAULT_NUMBER_OF_WORDS))
|
||||||
|
|
||||||
binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR))
|
binding.xkSeparator.setText(prefs.getString(PREF_KEY_SEPARATOR, DEFAULT_WORD_SEPARATOR))
|
||||||
binding.xkNumberSymbolMask.setText(prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK))
|
binding.xkNumberSymbolMask.setText(
|
||||||
|
prefs.getString(PREF_KEY_EXTRA_SYMBOLS_MASK, DEFAULT_EXTRA_SYMBOLS_MASK)
|
||||||
|
)
|
||||||
|
|
||||||
binding.xkPasswordText.typeface = monoTypeface
|
binding.xkPasswordText.typeface = monoTypeface
|
||||||
|
|
||||||
|
@ -85,8 +87,12 @@ class XkPasswordGeneratorDialogFragment : DialogFragment() {
|
||||||
.setMinimumWordLength(DEFAULT_MIN_WORD_LENGTH)
|
.setMinimumWordLength(DEFAULT_MIN_WORD_LENGTH)
|
||||||
.setMaximumWordLength(DEFAULT_MAX_WORD_LENGTH)
|
.setMaximumWordLength(DEFAULT_MAX_WORD_LENGTH)
|
||||||
.setSeparator(binding.xkSeparator.text.toString())
|
.setSeparator(binding.xkSeparator.text.toString())
|
||||||
.appendNumbers(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT })
|
.appendNumbers(
|
||||||
.appendSymbols(binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL })
|
binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_DIGIT }
|
||||||
|
)
|
||||||
|
.appendSymbols(
|
||||||
|
binding.xkNumberSymbolMask.text!!.count { c -> c == EXTRA_CHAR_PLACEHOLDER_SYMBOL }
|
||||||
|
)
|
||||||
.setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString()))
|
.setCapitalization(CapsType.valueOf(binding.xkCapType.selectedItem.toString()))
|
||||||
.create()
|
.create()
|
||||||
.fold(
|
.fold(
|
||||||
|
|
|
@ -24,7 +24,10 @@ class SelectFolderActivity : AppCompatActivity(R.layout.select_folder_layout) {
|
||||||
|
|
||||||
passwordList = SelectFolderFragment()
|
passwordList = SelectFolderFragment()
|
||||||
val args = Bundle()
|
val args = Bundle()
|
||||||
args.putString(PasswordStore.REQUEST_ARG_PATH, PasswordRepository.getRepositoryDirectory().absolutePath)
|
args.putString(
|
||||||
|
PasswordStore.REQUEST_ARG_PATH,
|
||||||
|
PasswordRepository.getRepositoryDirectory().absolutePath
|
||||||
|
)
|
||||||
|
|
||||||
passwordList.arguments = args
|
passwordList.arguments = args
|
||||||
|
|
||||||
|
@ -32,7 +35,9 @@ 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 { replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG) }
|
supportFragmentManager.commit {
|
||||||
|
replace(R.id.pgp_handler_linearlayout, passwordList, PASSWORD_FRAGMENT_TAG)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
|
|
|
@ -35,7 +35,10 @@ 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().onItemClicked { _, item -> listener.onFragmentInteraction(item) }
|
recyclerAdapter =
|
||||||
|
PasswordItemRecyclerAdapter().onItemClicked { _, item ->
|
||||||
|
listener.onFragmentInteraction(item)
|
||||||
|
}
|
||||||
binding.passRecycler.apply {
|
binding.passRecycler.apply {
|
||||||
layoutManager = LinearLayoutManager(requireContext())
|
layoutManager = LinearLayoutManager(requireContext())
|
||||||
itemAnimator = null
|
itemAnimator = null
|
||||||
|
@ -47,7 +50,9 @@ 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 -> recyclerAdapter.submitList(result.passwordItems) }
|
model.searchResult.observe(viewLifecycleOwner) { result ->
|
||||||
|
recyclerAdapter.submitList(result.passwordItems)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAttach(context: Context) {
|
override fun onAttach(context: Context) {
|
||||||
|
@ -58,12 +63,16 @@ class SelectFolderFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
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)
|
||||||
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
(requireActivity() as AppCompatActivity).supportActionBar?.setDisplayHomeAsUpEnabled(
|
||||||
|
true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure { throw ClassCastException("$context must implement OnFragmentInteractionListener") }
|
.onFailure {
|
||||||
|
throw ClassCastException("$context must implement OnFragmentInteractionListener")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val currentDir: File
|
val currentDir: File
|
||||||
|
|
|
@ -116,7 +116,8 @@ abstract class BaseGitActivity : ContinuationContainerActivity() {
|
||||||
"Your local repository appears to be an incomplete Git clone, please delete and re-clone from settings"
|
"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(
|
SSHException(
|
||||||
DisconnectReason.HOST_KEY_NOT_VERIFIABLE,
|
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,7 +136,9 @@ 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 && cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER) return true
|
if (cause is SSHException && cause.disconnectReason == DisconnectReason.AUTH_CANCELLED_BY_USER
|
||||||
|
)
|
||||||
|
return true
|
||||||
cause = cause.cause
|
cause = cause.cause
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
@ -154,7 +157,8 @@ abstract class BaseGitActivity : ContinuationContainerActivity() {
|
||||||
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.message == "Exhausted available authentication methods"))) {
|
(rootCause is UserAuthException &&
|
||||||
|
rootCause.message == "Exhausted available authentication methods"))) {
|
||||||
rootCause = rootCause.cause ?: break
|
rootCause = rootCause.cause ?: break
|
||||||
}
|
}
|
||||||
return rootCause
|
return rootCause
|
||||||
|
|
|
@ -55,7 +55,12 @@ class GitConfigActivity : BaseGitActivity() {
|
||||||
} else {
|
} else {
|
||||||
GitSettings.authorEmail = email
|
GitSettings.authorEmail = email
|
||||||
GitSettings.authorName = name
|
GitSettings.authorName = name
|
||||||
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() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -77,7 +82,8 @@ class GitConfigActivity : BaseGitActivity() {
|
||||||
if (repo != null) {
|
if (repo != null) {
|
||||||
binding.gitHeadStatus.text = headStatusMsg(repo)
|
binding.gitHeadStatus.text = headStatusMsg(repo)
|
||||||
// enable the abort button only if we're rebasing or merging
|
// enable the abort button only if we're rebasing or merging
|
||||||
val needsAbort = repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
|
val needsAbort =
|
||||||
|
repo.repositoryState.isRebasing || repo.repositoryState == RepositoryState.MERGING
|
||||||
binding.gitAbortRebase.isEnabled = needsAbort
|
binding.gitAbortRebase.isEnabled = needsAbort
|
||||||
binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
|
binding.gitAbortRebase.alpha = if (needsAbort) 1.0f else 0.5f
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,7 +89,12 @@ class GitServerConfigActivity : BaseGitActivity() {
|
||||||
binding.clearHostKeyButton.isVisible = GitSettings.hasSavedHostKey()
|
binding.clearHostKeyButton.isVisible = GitSettings.hasSavedHostKey()
|
||||||
binding.clearHostKeyButton.setOnClickListener {
|
binding.clearHostKeyButton.setOnClickListener {
|
||||||
GitSettings.clearSavedHostKey()
|
GitSettings.clearSavedHostKey()
|
||||||
Snackbar.make(binding.root, getString(R.string.clear_saved_host_key_success), Snackbar.LENGTH_LONG).show()
|
Snackbar.make(
|
||||||
|
binding.root,
|
||||||
|
getString(R.string.clear_saved_host_key_success),
|
||||||
|
Snackbar.LENGTH_LONG
|
||||||
|
)
|
||||||
|
.show()
|
||||||
it.isVisible = false
|
it.isVisible = false
|
||||||
}
|
}
|
||||||
binding.saveButton.setOnClickListener {
|
binding.saveButton.setOnClickListener {
|
||||||
|
@ -102,7 +107,9 @@ 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 { binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/")) }
|
.setPositiveButtonClickListener {
|
||||||
|
binding.serverUrl.setText(newUrl.replace(PORT_REGEX, "/"))
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
||||||
return@setOnClickListener
|
return@setOnClickListener
|
||||||
|
@ -110,7 +117,9 @@ 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 { @Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl") }
|
.setPositiveButtonClickListener {
|
||||||
|
@Suppress("SetTextI18n") binding.serverUrl.setText("ssh://$newUrl")
|
||||||
|
}
|
||||||
.build()
|
.build()
|
||||||
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
.show(supportFragmentManager, "SSH_SCHEME_WARNING")
|
||||||
return@setOnClickListener
|
return@setOnClickListener
|
||||||
|
@ -133,7 +142,12 @@ class GitServerConfigActivity : BaseGitActivity() {
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
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) {
|
||||||
|
@ -154,9 +168,14 @@ class GitServerConfigActivity : BaseGitActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GitSettings.UpdateConnectionSettingsResult.Valid -> {
|
GitSettings.UpdateConnectionSettingsResult.Valid -> {
|
||||||
if (isClone && PasswordRepository.getRepository(null) == null) PasswordRepository.initialize()
|
if (isClone && PasswordRepository.getRepository(null) == null)
|
||||||
|
PasswordRepository.initialize()
|
||||||
if (!isClone) {
|
if (!isClone) {
|
||||||
Snackbar.make(binding.root, getString(R.string.git_server_config_save_success), Snackbar.LENGTH_SHORT)
|
Snackbar.make(
|
||||||
|
binding.root,
|
||||||
|
getString(R.string.git_server_config_save_success),
|
||||||
|
Snackbar.LENGTH_SHORT
|
||||||
|
)
|
||||||
.show()
|
.show()
|
||||||
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
||||||
} else {
|
} else {
|
||||||
|
@ -206,7 +225,9 @@ class GitServerConfigActivity : BaseGitActivity() {
|
||||||
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() && !(localDirFiles.size == 1 && localDirFiles[0].name == ".git")
|
if (localDir.exists() &&
|
||||||
|
localDirFiles.isNotEmpty() &&
|
||||||
|
!(localDirFiles.size == 1 && localDirFiles[0].name == ".git")
|
||||||
) {
|
) {
|
||||||
MaterialAlertDialogBuilder(this)
|
MaterialAlertDialogBuilder(this)
|
||||||
.setTitle(R.string.dialog_delete_title)
|
.setTitle(R.string.dialog_delete_title)
|
||||||
|
@ -269,7 +290,9 @@ 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 { putExtra("cloning", true) }
|
return Intent(context, GitServerConfigActivity::class.java).apply {
|
||||||
|
putExtra("cloning", true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,7 +45,8 @@ class GitLogAdapter : RecyclerView.Adapter<GitLogAdapter.ViewHolder>() {
|
||||||
|
|
||||||
override fun getItemCount() = model.size
|
override fun getItemCount() = model.size
|
||||||
|
|
||||||
class ViewHolder(private val binding: GitLogRowLayoutBinding) : RecyclerView.ViewHolder(binding.root) {
|
class ViewHolder(private val binding: GitLogRowLayoutBinding) :
|
||||||
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
fun bind(commit: GitCommit) =
|
fun bind(commit: GitCommit) =
|
||||||
with(binding) {
|
with(binding) {
|
||||||
|
|
|
@ -24,7 +24,9 @@ class CloneFragment : Fragment(R.layout.fragment_clone) {
|
||||||
|
|
||||||
private val binding by viewBinding(FragmentCloneBinding::bind)
|
private val binding by viewBinding(FragmentCloneBinding::bind)
|
||||||
|
|
||||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
private val settings by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
|
requireActivity().applicationContext.sharedPrefs
|
||||||
|
}
|
||||||
|
|
||||||
private val cloneAction =
|
private val cloneAction =
|
||||||
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
|
|
@ -32,7 +32,9 @@ import me.msfjarvis.openpgpktx.util.OpenPgpApi
|
||||||
|
|
||||||
class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
|
class KeySelectionFragment : Fragment(R.layout.fragment_key_selection) {
|
||||||
|
|
||||||
private val settings by lazy(LazyThreadSafetyMode.NONE) { requireActivity().applicationContext.sharedPrefs }
|
private val settings by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
|
requireActivity().applicationContext.sharedPrefs
|
||||||
|
}
|
||||||
private val binding by viewBinding(FragmentKeySelectionBinding::bind)
|
private val binding by viewBinding(FragmentKeySelectionBinding::bind)
|
||||||
|
|
||||||
private val gpgKeySelectAction =
|
private val gpgKeySelectAction =
|
||||||
|
@ -45,13 +47,17 @@ 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(R.string.git_commit_gpg_id, getString(R.string.app_name)))
|
requireActivity()
|
||||||
|
.commitChange(getString(R.string.git_commit_gpg_id, getString(R.string.app_name)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finish()
|
finish()
|
||||||
} else {
|
} else {
|
||||||
requireActivity()
|
requireActivity()
|
||||||
.snackbar(message = getString(R.string.gpg_key_select_mandatory), length = Snackbar.LENGTH_LONG)
|
.snackbar(
|
||||||
|
message = getString(R.string.gpg_key_select_mandatory),
|
||||||
|
length = Snackbar.LENGTH_LONG
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,9 @@ 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) {
|
private val directorySelectIntent by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
Intent(requireContext(), DirectorySelectionActivity::class.java)
|
Intent(requireContext(), DirectorySelectionActivity::class.java)
|
||||||
}
|
}
|
||||||
|
@ -65,7 +67,9 @@ class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
|
||||||
externalDirectorySelectAction.launch(directorySelectIntent)
|
externalDirectorySelectAction.launch(directorySelectIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val repositoryUsePermGrantedAction = createPermGrantedAction { initializeRepositoryInfo() }
|
private val repositoryUsePermGrantedAction = createPermGrantedAction {
|
||||||
|
initializeRepositoryInfo()
|
||||||
|
}
|
||||||
|
|
||||||
private val repositoryChangePermGrantedAction = createPermGrantedAction {
|
private val repositoryChangePermGrantedAction = createPermGrantedAction {
|
||||||
repositoryInitAction.launch(directorySelectIntent)
|
repositoryInitAction.launch(directorySelectIntent)
|
||||||
|
@ -132,7 +136,12 @@ class RepoLocationFragment : Fragment(R.layout.fragment_repo_location) {
|
||||||
dir.isDirectory && // The directory, is really a directory
|
dir.isDirectory && // The directory, is really a directory
|
||||||
dir.listFilesRecursively().isNotEmpty() && // The directory contains files
|
dir.listFilesRecursively().isNotEmpty() && // The directory contains files
|
||||||
// The directory contains a non-zero number of password files
|
// The directory contains a non-zero number of password files
|
||||||
PasswordRepository.getPasswords(dir, PasswordRepository.getRepositoryDirectory(), sortOrder).isNotEmpty()
|
PasswordRepository.getPasswords(
|
||||||
|
dir,
|
||||||
|
PasswordRepository.getRepositoryDirectory(),
|
||||||
|
sortOrder
|
||||||
|
)
|
||||||
|
.isNotEmpty()
|
||||||
) {
|
) {
|
||||||
PasswordRepository.closeRepository()
|
PasswordRepository.closeRepository()
|
||||||
return true
|
return true
|
||||||
|
|
|
@ -27,6 +27,8 @@ class WelcomeFragment : Fragment(R.layout.fragment_welcome) {
|
||||||
binding.letsGo.setOnClickListener {
|
binding.letsGo.setOnClickListener {
|
||||||
parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance())
|
parentFragmentManager.performTransactionWithBackStack(CloneFragment.newInstance())
|
||||||
}
|
}
|
||||||
binding.settingsButton.setOnClickListener { startActivity(Intent(requireContext(), SettingsActivity::class.java)) }
|
binding.settingsButton.setOnClickListener {
|
||||||
|
startActivity(Intent(requireContext(), SettingsActivity::class.java))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -77,8 +77,12 @@ 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 { ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET") }
|
binding.fab.setOnClickListener {
|
||||||
childFragmentManager.setFragmentResultListener(ITEM_CREATION_REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
ItemCreationBottomSheet().show(childFragmentManager, "BOTTOM_SHEET")
|
||||||
|
}
|
||||||
|
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()
|
||||||
ACTION_PASSWORD -> requireStore().createPassword()
|
ACTION_PASSWORD -> requireStore().createPassword()
|
||||||
|
@ -88,7 +92,8 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
|
|
||||||
private fun initializePasswordList() {
|
private fun initializePasswordList() {
|
||||||
val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
|
val gitDir = File(PasswordRepository.getRepositoryDirectory(), ".git")
|
||||||
val hasGitDir = gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
|
val hasGitDir =
|
||||||
|
gitDir.exists() && gitDir.isDirectory && (gitDir.listFiles()?.isNotEmpty() == true)
|
||||||
binding.swipeRefresher.setOnRefreshListener {
|
binding.swipeRefresher.setOnRefreshListener {
|
||||||
if (!hasGitDir) {
|
if (!hasGitDir) {
|
||||||
requireStore().refreshPasswordList()
|
requireStore().refreshPasswordList()
|
||||||
|
@ -118,7 +123,9 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
binding.swipeRefresher.isRefreshing = false
|
binding.swipeRefresher.isRefreshing = false
|
||||||
refreshPasswordList()
|
refreshPasswordList()
|
||||||
},
|
},
|
||||||
failure = { err -> promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false } },
|
failure = { err ->
|
||||||
|
promptOnErrorHandler(err) { binding.swipeRefresher.isRefreshing = false }
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -135,10 +142,16 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
binding.swipeRefresher.isEnabled = selection.isEmpty
|
binding.swipeRefresher.isEnabled = selection.isEmpty
|
||||||
|
|
||||||
if (actionMode == null)
|
if (actionMode == null)
|
||||||
actionMode = requireStore().startSupportActionMode(actionModeCallback) ?: return@onSelectionChanged
|
actionMode =
|
||||||
|
requireStore().startSupportActionMode(actionModeCallback) ?: 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()
|
||||||
|
)
|
||||||
actionMode!!.invalidate()
|
actionMode!!.invalidate()
|
||||||
} else {
|
} else {
|
||||||
actionMode!!.finish()
|
actionMode!!.finish()
|
||||||
|
@ -171,14 +184,18 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
recyclerView.scrollToPosition(0)
|
recyclerView.scrollToPosition(0)
|
||||||
}
|
}
|
||||||
scrollTarget != null -> {
|
scrollTarget != null -> {
|
||||||
scrollTarget?.let { recyclerView.scrollToPosition(recyclerAdapter.getPositionForFile(it)) }
|
scrollTarget?.let {
|
||||||
|
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
|
// When the result is not filtered and there is a saved scroll position for
|
||||||
// it,
|
// it,
|
||||||
// we try to restore it.
|
// we try to restore it.
|
||||||
recyclerViewStateToRestore?.let { recyclerView.layoutManager!!.onRestoreInstanceState(it) }
|
recyclerViewStateToRestore?.let {
|
||||||
|
recyclerView.layoutManager!!.onRestoreInstanceState(it)
|
||||||
|
}
|
||||||
recyclerViewStateToRestore = null
|
recyclerViewStateToRestore = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -201,7 +218,8 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
// but may be called multiple times if the mode is invalidated.
|
// but 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 {
|
||||||
val selectedItems = recyclerAdapter.getSelectedItems()
|
val selectedItems = recyclerAdapter.getSelectedItems()
|
||||||
menu.findItem(R.id.menu_edit_password).isVisible = selectedItems.all { it.type == PasswordItem.TYPE_CATEGORY }
|
menu.findItem(R.id.menu_edit_password).isVisible =
|
||||||
|
selectedItems.all { it.type == PasswordItem.TYPE_CATEGORY }
|
||||||
menu.findItem(R.id.menu_pin_password).isVisible =
|
menu.findItem(R.id.menu_pin_password).isVisible =
|
||||||
selectedItems.size == 1 && selectedItems[0].type == PasswordItem.TYPE_PASSWORD
|
selectedItems.size == 1 && selectedItems[0].type == PasswordItem.TYPE_PASSWORD
|
||||||
return true
|
return true
|
||||||
|
@ -227,7 +245,10 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
}
|
}
|
||||||
R.id.menu_pin_password -> {
|
R.id.menu_pin_password -> {
|
||||||
val passwordItem = recyclerAdapter.getSelectedItems()[0]
|
val passwordItem = recyclerAdapter.getSelectedItems()[0]
|
||||||
shortcutHandler.addPinnedShortcut(passwordItem, passwordItem.createAuthEnabledIntent(requireContext()))
|
shortcutHandler.addPinnedShortcut(
|
||||||
|
passwordItem,
|
||||||
|
passwordItem.createAuthEnabledIntent(requireContext())
|
||||||
|
)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
else -> false
|
else -> false
|
||||||
|
@ -244,7 +265,8 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
|
|
||||||
private fun animateFab(show: Boolean) =
|
private fun animateFab(show: Boolean) =
|
||||||
with(binding.fab) {
|
with(binding.fab) {
|
||||||
val animation = AnimationUtils.loadAnimation(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?) {}
|
||||||
|
@ -258,7 +280,11 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
animate().rotationBy(if (show) -90f else 90f).setStartDelay(if (show) 100 else 0).setDuration(100).start()
|
animate()
|
||||||
|
.rotationBy(if (show) -90f else 90f)
|
||||||
|
.setStartDelay(if (show) 100 else 0)
|
||||||
|
.setDuration(100)
|
||||||
|
.start()
|
||||||
startAnimation(animation)
|
startAnimation(animation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -269,10 +295,15 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
listener =
|
listener =
|
||||||
object : OnFragmentInteractionListener {
|
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 =
|
||||||
preferences.edit { putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString()) }
|
context.getSharedPreferences("recent_password_history", Context.MODE_PRIVATE)
|
||||||
|
preferences.edit {
|
||||||
|
putString(item.file.absolutePath.base64(), System.currentTimeMillis().toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
if (item.type == PasswordItem.TYPE_CATEGORY) {
|
||||||
|
@ -287,7 +318,9 @@ 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
|
||||||
|
@ -322,7 +355,10 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
|
||||||
|
|
||||||
fun navigateTo(file: File) {
|
fun navigateTo(file: File) {
|
||||||
requireStore().clearSearch()
|
requireStore().clearSearch()
|
||||||
model.navigateTo(file, recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState())
|
model.navigateTo(
|
||||||
|
file,
|
||||||
|
recyclerViewState = binding.passRecycler.layoutManager!!.onSaveInstanceState()
|
||||||
|
)
|
||||||
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
requireStore().supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -82,7 +82,9 @@ class PasswordStore : BaseGitActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private val storagePermissionRequest =
|
private val storagePermissionRequest =
|
||||||
registerForActivityResult(RequestPermission()) { granted -> if (granted) checkLocalRepository() }
|
registerForActivityResult(RequestPermission()) { granted ->
|
||||||
|
if (granted) checkLocalRepository()
|
||||||
|
}
|
||||||
|
|
||||||
private val directorySelectAction =
|
private val directorySelectAction =
|
||||||
registerForActivityResult(StartActivityForResult()) { result ->
|
registerForActivityResult(StartActivityForResult()) { result ->
|
||||||
|
@ -128,7 +130,13 @@ 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(R.string.password_exists_message, destinationLongName, sourceLongName))
|
.setMessage(
|
||||||
|
resources.getString(
|
||||||
|
R.string.password_exists_message,
|
||||||
|
destinationLongName,
|
||||||
|
sourceLongName
|
||||||
|
)
|
||||||
|
)
|
||||||
.setPositiveButton(R.string.dialog_ok) { _, _ ->
|
.setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||||
launch(Dispatchers.IO) { moveFile(source, destinationFile) }
|
launch(Dispatchers.IO) { moveFile(source, destinationFile) }
|
||||||
}
|
}
|
||||||
|
@ -143,11 +151,16 @@ class PasswordStore : BaseGitActivity() {
|
||||||
1 -> {
|
1 -> {
|
||||||
val source = File(filesToMove[0])
|
val source = File(filesToMove[0])
|
||||||
val basename = source.nameWithoutExtension
|
val basename = source.nameWithoutExtension
|
||||||
val sourceLongName = getLongName(requireNotNull(source.parent), repositoryPath, basename)
|
val sourceLongName =
|
||||||
|
getLongName(requireNotNull(source.parent), repositoryPath, basename)
|
||||||
val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
|
val destinationLongName = getLongName(target.absolutePath, repositoryPath, basename)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
commitChange(
|
commitChange(
|
||||||
resources.getString(R.string.git_commit_move_text, sourceLongName, destinationLongName),
|
resources.getString(
|
||||||
|
R.string.git_commit_move_text,
|
||||||
|
sourceLongName,
|
||||||
|
destinationLongName
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -168,8 +181,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 ||
|
||||||
!searchItem.isActionViewExpanded
|
keyCode == KeyEvent.KEYCODE_F && event.isCtrlPressed) && !searchItem.isActionViewExpanded
|
||||||
) {
|
) {
|
||||||
searchItem.expandActionView()
|
searchItem.expandActionView()
|
||||||
return true
|
return true
|
||||||
|
@ -202,7 +215,9 @@ 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 { if (dir != basePath) title = dir.name else setTitle(R.string.app_name) }
|
supportActionBar!!.apply {
|
||||||
|
if (dir != basePath) title = dir.name else setTitle(R.string.app_name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -253,7 +268,8 @@ 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()) model.navigateTo(newDirectory = model.currentDir.value!!, pushPreviousLocation = false)
|
if (filter.isEmpty())
|
||||||
|
model.navigateTo(newDirectory = model.currentDir.value!!, pushPreviousLocation = false)
|
||||||
else model.search(filter)
|
else model.search(filter)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@ -288,7 +304,9 @@ class PasswordStore : BaseGitActivity() {
|
||||||
.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 { startActivity(Intent(this, SettingsActivity::class.java)) }.onFailure { e -> e.printStackTrace() }
|
runCatching { startActivity(Intent(this, SettingsActivity::class.java)) }.onFailure { e ->
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
R.id.git_push -> {
|
R.id.git_push -> {
|
||||||
if (!PasswordRepository.isInitialized) {
|
if (!PasswordRepository.isInitialized) {
|
||||||
|
@ -372,7 +390,8 @@ 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 || settings.getBoolean(PreferenceKeys.REPO_CHANGED, false)) {
|
if (getPasswordFragment() == null || 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)
|
||||||
|
@ -403,7 +422,9 @@ class PasswordStore : BaseGitActivity() {
|
||||||
fun decryptPassword(item: PasswordItem) {
|
fun decryptPassword(item: PasswordItem) {
|
||||||
val authDecryptIntent = item.createAuthEnabledIntent(this)
|
val authDecryptIntent = item.createAuthEnabledIntent(this)
|
||||||
val decryptIntent =
|
val decryptIntent =
|
||||||
(authDecryptIntent.clone() as Intent).setComponent(ComponentName(this, DecryptActivity::class.java))
|
(authDecryptIntent.clone() as Intent).setComponent(
|
||||||
|
ComponentName(this, DecryptActivity::class.java)
|
||||||
|
)
|
||||||
|
|
||||||
startActivity(decryptIntent)
|
startActivity(decryptIntent)
|
||||||
|
|
||||||
|
@ -439,7 +460,9 @@ class PasswordStore : BaseGitActivity() {
|
||||||
|
|
||||||
fun deletePasswords(selectedItems: List<PasswordItem>) {
|
fun deletePasswords(selectedItems: List<PasswordItem>) {
|
||||||
var size = 0
|
var size = 0
|
||||||
selectedItems.forEach { if (it.file.isFile) size++ else size += it.file.listFilesRecursively().size }
|
selectedItems.forEach {
|
||||||
|
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()
|
||||||
|
@ -497,7 +520,10 @@ class PasswordStore : BaseGitActivity() {
|
||||||
* @see [CategoryRenameError]
|
* @see [CategoryRenameError]
|
||||||
* @see [isInsideRepository]
|
* @see [isInsideRepository]
|
||||||
*/
|
*/
|
||||||
private fun renameCategory(oldCategory: PasswordItem, error: CategoryRenameError = CategoryRenameError.None) {
|
private fun renameCategory(
|
||||||
|
oldCategory: PasswordItem,
|
||||||
|
error: CategoryRenameError = CategoryRenameError.None
|
||||||
|
) {
|
||||||
val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null)
|
val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null)
|
||||||
val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text)
|
val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text)
|
||||||
|
|
||||||
|
@ -513,16 +539,19 @@ class PasswordStore : BaseGitActivity() {
|
||||||
.setPositiveButton(R.string.dialog_ok) { _, _ ->
|
.setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||||
val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}")
|
val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}")
|
||||||
when {
|
when {
|
||||||
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 ->
|
else ->
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
moveFile(oldCategory.file, newCategory)
|
moveFile(oldCategory.file, newCategory)
|
||||||
|
|
||||||
// associate the new category with the last category's timestamp in
|
// associate the new category with the last category's timestamp in
|
||||||
// history
|
// 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) {
|
||||||
preference.edit {
|
preference.edit {
|
||||||
|
@ -533,7 +562,11 @@ class PasswordStore : BaseGitActivity() {
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
commitChange(
|
commitChange(
|
||||||
resources.getString(R.string.git_commit_move_text, oldCategory.name, newCategory.name),
|
resources.getString(
|
||||||
|
R.string.git_commit_move_text,
|
||||||
|
oldCategory.name,
|
||||||
|
newCategory.name
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -584,7 +617,9 @@ class PasswordStore : BaseGitActivity() {
|
||||||
// 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
|
||||||
// starting at the destination folder.
|
// starting at the destination folder.
|
||||||
source.listFilesRecursively().associateWith { destinationFile.resolve(it.relativeTo(source)) }
|
source.listFilesRecursively().associateWith {
|
||||||
|
destinationFile.resolve(it.relativeTo(source))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
mapOf(source to destinationFile)
|
mapOf(source to destinationFile)
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,9 @@ private val WEB_ADDRESS_REGEX = Patterns.WEB_URL.toRegex()
|
||||||
class ProxySelectorActivity : AppCompatActivity() {
|
class ProxySelectorActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private val binding by viewBinding(ActivityProxySelectorBinding::inflate)
|
private val binding by viewBinding(ActivityProxySelectorBinding::inflate)
|
||||||
private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) { applicationContext.getEncryptedProxyPrefs() }
|
private val proxyPrefs by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
|
applicationContext.getEncryptedProxyPrefs()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
@ -36,7 +38,9 @@ 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 { proxyPort.setText("$it") }
|
proxyPrefs.getInt(PreferenceKeys.PROXY_PORT, -1).takeIf { it != -1 }?.let {
|
||||||
|
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, _, _, _ ->
|
||||||
|
@ -54,10 +58,18 @@ class ProxySelectorActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private fun saveSettings() {
|
private fun saveSettings() {
|
||||||
proxyPrefs.edit {
|
proxyPrefs.edit {
|
||||||
binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyHost = it }
|
binding.proxyHost.text?.toString()?.takeIf { it.isNotEmpty() }.let {
|
||||||
binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyUsername = it }
|
GitSettings.proxyHost = it
|
||||||
binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let { GitSettings.proxyPort = it.toInt() }
|
}
|
||||||
binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let { GitSettings.proxyPassword = it }
|
binding.proxyUser.text?.toString()?.takeIf { it.isNotEmpty() }.let {
|
||||||
|
GitSettings.proxyUsername = it
|
||||||
|
}
|
||||||
|
binding.proxyPort.text?.toString()?.takeIf { it.isNotEmpty() }?.let {
|
||||||
|
GitSettings.proxyPort = it.toInt()
|
||||||
|
}
|
||||||
|
binding.proxyPassword.text?.toString()?.takeIf { it.isNotEmpty() }.let {
|
||||||
|
GitSettings.proxyPassword = it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ProxyUtils.setDefaultProxy()
|
ProxyUtils.setDefaultProxy()
|
||||||
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
Handler(Looper.getMainLooper()).postDelayed(500) { finish() }
|
||||||
|
|
|
@ -59,13 +59,18 @@ class AutofillSettings(private val activity: FragmentActivity) : SettingsProvide
|
||||||
val appLabel = it.first
|
val appLabel = it.first
|
||||||
val supportDescription =
|
val supportDescription =
|
||||||
when (it.second) {
|
when (it.second) {
|
||||||
BrowserAutofillSupportLevel.None -> activity.getString(R.string.oreo_autofill_no_support)
|
BrowserAutofillSupportLevel.None ->
|
||||||
BrowserAutofillSupportLevel.FlakyFill -> activity.getString(R.string.oreo_autofill_flaky_fill_support)
|
activity.getString(R.string.oreo_autofill_no_support)
|
||||||
|
BrowserAutofillSupportLevel.FlakyFill ->
|
||||||
|
activity.getString(R.string.oreo_autofill_flaky_fill_support)
|
||||||
BrowserAutofillSupportLevel.PasswordFill ->
|
BrowserAutofillSupportLevel.PasswordFill ->
|
||||||
activity.getString(R.string.oreo_autofill_password_fill_support)
|
activity.getString(R.string.oreo_autofill_password_fill_support)
|
||||||
BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility ->
|
BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility ->
|
||||||
activity.getString(R.string.oreo_autofill_password_fill_and_conditional_save_support)
|
activity.getString(
|
||||||
BrowserAutofillSupportLevel.GeneralFill -> activity.getString(R.string.oreo_autofill_general_fill_support)
|
R.string.oreo_autofill_password_fill_and_conditional_save_support
|
||||||
|
)
|
||||||
|
BrowserAutofillSupportLevel.GeneralFill ->
|
||||||
|
activity.getString(R.string.oreo_autofill_general_fill_support)
|
||||||
BrowserAutofillSupportLevel.GeneralFillAndSave ->
|
BrowserAutofillSupportLevel.GeneralFillAndSave ->
|
||||||
activity.getString(R.string.oreo_autofill_general_fill_and_save_support)
|
activity.getString(R.string.oreo_autofill_general_fill_and_save_support)
|
||||||
}
|
}
|
||||||
|
@ -102,8 +107,10 @@ class AutofillSettings(private val activity: FragmentActivity) : SettingsProvide
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val values = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_values)
|
val values =
|
||||||
val titles = activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_entries)
|
activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_values)
|
||||||
|
val titles =
|
||||||
|
activity.resources.getStringArray(R.array.oreo_autofill_directory_structure_entries)
|
||||||
val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) }
|
val items = values.zip(titles).map { SelectionItem(it.first, it.second, null) }
|
||||||
singleChoice(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE, items) {
|
singleChoice(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE, items) {
|
||||||
initialSelection = DirectoryStructure.DEFAULT.value
|
initialSelection = DirectoryStructure.DEFAULT.value
|
||||||
|
|
|
@ -39,7 +39,9 @@ class DirectorySelectionActivity : AppCompatActivity() {
|
||||||
MaterialAlertDialogBuilder(this)
|
MaterialAlertDialogBuilder(this)
|
||||||
.setTitle(resources.getString(R.string.sdcard_root_warning_title))
|
.setTitle(resources.getString(R.string.sdcard_root_warning_title))
|
||||||
.setMessage(resources.getString(R.string.sdcard_root_warning_message))
|
.setMessage(resources.getString(R.string.sdcard_root_warning_message))
|
||||||
.setPositiveButton(resources.getString(R.string.sdcard_root_warning_remove_everything)) { _, _ ->
|
.setPositiveButton(resources.getString(R.string.sdcard_root_warning_remove_everything)) {
|
||||||
|
_,
|
||||||
|
_ ->
|
||||||
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) }
|
prefs.edit { putString(PreferenceKeys.GIT_EXTERNAL_REPO, uri.path) }
|
||||||
}
|
}
|
||||||
.setNegativeButton(R.string.dialog_cancel, null)
|
.setNegativeButton(R.string.dialog_cancel, null)
|
||||||
|
|
|
@ -26,7 +26,8 @@ class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider
|
||||||
builder.apply {
|
builder.apply {
|
||||||
val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
|
val themeValues = activity.resources.getStringArray(R.array.app_theme_values)
|
||||||
val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
|
val themeOptions = activity.resources.getStringArray(R.array.app_theme_options)
|
||||||
val themeItems = themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
|
val themeItems =
|
||||||
|
themeValues.zip(themeOptions).map { SelectionItem(it.first, it.second, null) }
|
||||||
singleChoice(PreferenceKeys.APP_THEME, themeItems) {
|
singleChoice(PreferenceKeys.APP_THEME, themeItems) {
|
||||||
initialSelection = activity.resources.getString(R.string.app_theme_def)
|
initialSelection = activity.resources.getString(R.string.app_theme_def)
|
||||||
titleRes = R.string.pref_app_theme_title
|
titleRes = R.string.pref_app_theme_title
|
||||||
|
@ -64,7 +65,8 @@ class GeneralSettings(private val activity: FragmentActivity) : SettingsProvider
|
||||||
defaultValue = false
|
defaultValue = false
|
||||||
enabled = canAuthenticate
|
enabled = canAuthenticate
|
||||||
summaryRes =
|
summaryRes =
|
||||||
if (canAuthenticate) R.string.pref_biometric_auth_summary else R.string.pref_biometric_auth_summary_error
|
if (canAuthenticate) R.string.pref_biometric_auth_summary
|
||||||
|
else R.string.pref_biometric_auth_summary_error
|
||||||
onClick {
|
onClick {
|
||||||
enabled = false
|
enabled = false
|
||||||
val isChecked = checked
|
val isChecked = checked
|
||||||
|
|
|
@ -37,7 +37,9 @@ import dev.msfjarvis.aps.util.settings.PreferenceKeys
|
||||||
|
|
||||||
class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider {
|
class RepositorySettings(private val activity: FragmentActivity) : SettingsProvider {
|
||||||
|
|
||||||
private val encryptedPreferences by lazy(LazyThreadSafetyMode.NONE) { activity.getEncryptedGitPrefs() }
|
private val encryptedPreferences by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
|
activity.getEncryptedGitPrefs()
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T : FragmentActivity> launchActivity(clazz: Class<T>) {
|
private fun <T : FragmentActivity> launchActivity(clazz: Class<T>) {
|
||||||
activity.startActivity(Intent(activity, clazz))
|
activity.startActivity(Intent(activity, clazz))
|
||||||
|
@ -47,7 +49,9 @@ 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) { _, _ -> launchActivity(DirectorySelectionActivity::class.java) }
|
.setPositiveButton(R.string.dialog_ok) { _, _ ->
|
||||||
|
launchActivity(DirectorySelectionActivity::class.java)
|
||||||
|
}
|
||||||
.setNegativeButton(R.string.dialog_cancel, null)
|
.setNegativeButton(R.string.dialog_cancel, null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
@ -130,7 +134,9 @@ 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() ?: false
|
visible =
|
||||||
|
activity.sharedPrefs.getString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)?.isNotEmpty()
|
||||||
|
?: 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
|
||||||
|
@ -160,7 +166,9 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi
|
||||||
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
|
removeDynamicShortcuts(dynamicShortcuts.map { it.id }.toMutableList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
activity.sharedPrefs.edit { putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false) }
|
activity.sharedPrefs.edit {
|
||||||
|
putBoolean(PreferenceKeys.REPOSITORY_INITIALIZED, false)
|
||||||
|
}
|
||||||
dialogInterface.cancel()
|
dialogInterface.cancel()
|
||||||
activity.finish()
|
activity.finish()
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,7 +68,9 @@ class SettingsActivity : AppCompatActivity() {
|
||||||
getString(subScreen.titleRes)
|
getString(subScreen.titleRes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
savedInstanceState?.getParcelable<PreferencesAdapter.SavedState>("adapter")?.let(adapter::loadSavedState)
|
savedInstanceState
|
||||||
|
?.getParcelable<PreferencesAdapter.SavedState>("adapter")
|
||||||
|
?.let(adapter::loadSavedState)
|
||||||
binding.preferenceRecyclerView.adapter = adapter
|
binding.preferenceRecyclerView.adapter = adapter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,9 @@ 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) { _, _ -> (activity as? SshKeyGenActivity)?.finish() }
|
setNegativeButton(R.string.ssh_keygen_later) { _, _ ->
|
||||||
|
(activity as? SshKeyGenActivity)?.finish()
|
||||||
|
}
|
||||||
setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
|
setPositiveButton(R.string.ssh_keygen_share) { _, _ ->
|
||||||
val sendIntent =
|
val sendIntent =
|
||||||
Intent().apply {
|
Intent().apply {
|
||||||
|
|
|
@ -30,9 +30,15 @@ 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 -> SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) }),
|
Rsa({ requireAuthentication ->
|
||||||
Ecdsa({ requireAuthentication -> SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) }),
|
SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, 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() {
|
||||||
|
@ -50,7 +56,9 @@ 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) { _, _ -> lifecycleScope.launch { generate() } }
|
setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ ->
|
||||||
|
lifecycleScope.launch { generate() }
|
||||||
|
}
|
||||||
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
|
setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> finish() }
|
||||||
show()
|
show()
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,12 @@ class SshKeyImportActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
runCatching {
|
runCatching {
|
||||||
SshKey.import(uri)
|
SshKey.import(uri)
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,8 @@ import dev.msfjarvis.aps.R
|
||||||
object BiometricAuthenticator {
|
object BiometricAuthenticator {
|
||||||
|
|
||||||
private const val TAG = "BiometricAuthenticator"
|
private const val TAG = "BiometricAuthenticator"
|
||||||
private const val validAuthenticators = Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
|
private const val validAuthenticators =
|
||||||
|
Authenticators.DEVICE_CREDENTIAL or Authenticators.BIOMETRIC_WEAK
|
||||||
|
|
||||||
sealed class Result {
|
sealed class Result {
|
||||||
data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
|
data class Success(val cryptoObject: BiometricPrompt.CryptoObject?) : Result()
|
||||||
|
@ -29,7 +30,8 @@ object BiometricAuthenticator {
|
||||||
}
|
}
|
||||||
|
|
||||||
fun canAuthenticate(activity: FragmentActivity): Boolean {
|
fun canAuthenticate(activity: FragmentActivity): Boolean {
|
||||||
return BiometricManager.from(activity).canAuthenticate(validAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
|
return BiometricManager.from(activity).canAuthenticate(validAuthenticators) ==
|
||||||
|
BiometricManager.BIOMETRIC_SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
fun authenticate(
|
fun authenticate(
|
||||||
|
@ -55,7 +57,11 @@ object BiometricAuthenticator {
|
||||||
BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> {
|
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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -77,7 +83,11 @@ object BiometricAuthenticator {
|
||||||
.setTitle(activity.getString(dialogTitleRes))
|
.setTitle(activity.getString(dialogTitleRes))
|
||||||
.setAllowedAuthenticators(validAuthenticators)
|
.setAllowedAuthenticators(validAuthenticators)
|
||||||
.build()
|
.build()
|
||||||
BiometricPrompt(activity, ContextCompat.getMainExecutor(activity.applicationContext), authCallback)
|
BiometricPrompt(
|
||||||
|
activity,
|
||||||
|
ContextCompat.getMainExecutor(activity.applicationContext),
|
||||||
|
authCallback
|
||||||
|
)
|
||||||
.authenticate(promptInfo)
|
.authenticate(promptInfo)
|
||||||
} else {
|
} else {
|
||||||
callback(Result.HardwareUnavailableOrDisabled)
|
callback(Result.HardwareUnavailableOrDisabled)
|
||||||
|
|
|
@ -61,7 +61,11 @@ class Api30AutofillResponseBuilder(form: FillableForm) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? {
|
private fun makeMatchDataset(
|
||||||
|
context: Context,
|
||||||
|
file: File,
|
||||||
|
imeSpec: InlinePresentationSpec?
|
||||||
|
): Dataset? {
|
||||||
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
|
||||||
val metadata = makeFillMatchMetadata(context, file)
|
val metadata = makeFillMatchMetadata(context, file)
|
||||||
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
|
||||||
|
@ -82,12 +86,21 @@ 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
|
||||||
val metadata = makeFillOtpFromSmsMetadata(context)
|
val metadata = makeFillOtpFromSmsMetadata(context)
|
||||||
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
|
val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
|
||||||
return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec)
|
return makeIntentDataset(
|
||||||
|
context,
|
||||||
|
AutofillAction.FillOtpFromSms,
|
||||||
|
intentSender,
|
||||||
|
metadata,
|
||||||
|
imeSpec
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun makePublisherChangedDataset(
|
private fun makePublisherChangedDataset(
|
||||||
|
@ -150,7 +163,12 @@ class Api30AutofillResponseBuilder(form: FillableForm) {
|
||||||
addDataset(it)
|
addDataset(it)
|
||||||
}
|
}
|
||||||
if (datasetCount == 0) return null
|
if (datasetCount == 0) return null
|
||||||
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)
|
||||||
setIgnoredIds(*ignoredIds.toTypedArray())
|
setIgnoredIds(*ignoredIds.toTypedArray())
|
||||||
|
@ -177,7 +195,11 @@ 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)
|
AutofillMatcher.getMatchesFor(context, formOrigin)
|
||||||
.fold(
|
.fold(
|
||||||
success = { matchedFiles ->
|
success = { matchedFiles ->
|
||||||
|
|
|
@ -35,7 +35,9 @@ private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences
|
||||||
}
|
}
|
||||||
|
|
||||||
class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
|
class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
|
||||||
Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
|
Exception(
|
||||||
|
"The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app"
|
||||||
|
) {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
require(formOrigin is FormOrigin.App)
|
require(formOrigin is FormOrigin.App)
|
||||||
|
@ -50,10 +52,12 @@ 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) = "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
|
private fun tokenKey(formOrigin: FormOrigin.App) =
|
||||||
|
"$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
|
||||||
|
|
||||||
private const val PREFERENCE_PREFIX_MATCHES = "matches;"
|
private const val PREFERENCE_PREFIX_MATCHES = "matches;"
|
||||||
private fun matchesKey(formOrigin: FormOrigin) = "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
|
private fun matchesKey(formOrigin: FormOrigin) =
|
||||||
|
"$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) {
|
||||||
|
@ -61,7 +65,8 @@ 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 = context.autofillAppMatches.getString(tokenKey(formOrigin), null) ?: return false
|
val storedCertificatesHash =
|
||||||
|
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" }
|
||||||
|
@ -91,15 +96,21 @@ class AutofillMatcher {
|
||||||
* time the user associated an entry with it, an [AutofillPublisherChangedException] will be
|
* time the user associated an entry with it, an [AutofillPublisherChangedException] 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 = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
|
val matchedFiles =
|
||||||
|
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 { putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) }
|
matchPreferences.edit {
|
||||||
|
putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -127,7 +138,8 @@ class AutofillMatcher {
|
||||||
throw AutofillPublisherChangedException(formOrigin)
|
throw AutofillPublisherChangedException(formOrigin)
|
||||||
}
|
}
|
||||||
val matchPreferences = context.matchPreferences(formOrigin)
|
val matchPreferences = context.matchPreferences(formOrigin)
|
||||||
val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
|
val matchedFiles =
|
||||||
|
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(
|
||||||
|
@ -138,7 +150,9 @@ class AutofillMatcher {
|
||||||
.show()
|
.show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
matchPreferences.edit { putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) }
|
matchPreferences.edit {
|
||||||
|
putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet())
|
||||||
|
}
|
||||||
storeFormOriginHash(context, formOrigin)
|
storeFormOriginHash(context, formOrigin)
|
||||||
d { "Stored match for $formOrigin" }
|
d { "Stored match for $formOrigin" }
|
||||||
}
|
}
|
||||||
|
@ -153,7 +167,8 @@ class AutofillMatcher {
|
||||||
delete: Collection<File> = emptyList()
|
delete: Collection<File> = emptyList()
|
||||||
) {
|
) {
|
||||||
val deletePathList = delete.map { it.absolutePath }
|
val deletePathList = delete.map { it.absolutePath }
|
||||||
val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }.mapKeys { it.key.absolutePath }
|
val oldNewPathMap =
|
||||||
|
moveFromTo.mapValues { it.value.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
|
||||||
|
|
|
@ -96,7 +96,8 @@ enum class DirectoryStructure(val value: String) {
|
||||||
when (this) {
|
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 -> "${parentFile.name}/${file.nameWithoutExtension}" }
|
DirectoryBased ->
|
||||||
|
file.parentFile?.let { parentFile -> "${parentFile.name}/${file.nameWithoutExtension}" }
|
||||||
?: file.nameWithoutExtension
|
?: file.nameWithoutExtension
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,7 +139,8 @@ 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 ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
|
val username =
|
||||||
|
entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
|
||||||
return Credentials(username, entry.password, entry.totp.value)
|
return Credentials(username, entry.password, entry.totp.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -154,7 +154,10 @@ 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(
|
setHeader(
|
||||||
makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))
|
makeRemoteView(
|
||||||
|
context,
|
||||||
|
makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
makeSaveInfo()?.let { setSaveInfo(it) }
|
makeSaveInfo()?.let { setSaveInfo(it) }
|
||||||
|
|
|
@ -51,13 +51,15 @@ fun makeInlinePresentation(
|
||||||
|
|
||||||
if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null
|
if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null
|
||||||
|
|
||||||
val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0)
|
val launchIntent =
|
||||||
|
PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0)
|
||||||
val slice =
|
val slice =
|
||||||
InlineSuggestionUi.newContentBuilder(launchIntent).run {
|
InlineSuggestionUi.newContentBuilder(launchIntent).run {
|
||||||
setTitle(metadata.title)
|
setTitle(metadata.title)
|
||||||
if (metadata.subtitle != null) setSubtitle(metadata.subtitle)
|
if (metadata.subtitle != null) 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
|
||||||
|
@ -69,13 +71,19 @@ fun makeInlinePresentation(
|
||||||
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) ?: directoryStructure.getAccountPartFor(relativeFile)!!
|
val title =
|
||||||
|
directoryStructure.getIdentifierFor(relativeFile)
|
||||||
|
?: directoryStructure.getAccountPartFor(relativeFile)!!
|
||||||
val subtitle = directoryStructure.getAccountPartFor(relativeFile)
|
val subtitle = directoryStructure.getAccountPartFor(relativeFile)
|
||||||
return DatasetMetadata(title, subtitle, R.drawable.ic_person_black_24dp)
|
return DatasetMetadata(title, subtitle, R.drawable.ic_person_black_24dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun makeSearchAndFillMetadata(context: Context) =
|
fun makeSearchAndFillMetadata(context: Context) =
|
||||||
DatasetMetadata(context.getString(R.string.oreo_autofill_search_in_store), null, R.drawable.ic_search_black_24dp)
|
DatasetMetadata(
|
||||||
|
context.getString(R.string.oreo_autofill_search_in_store),
|
||||||
|
null,
|
||||||
|
R.drawable.ic_search_black_24dp
|
||||||
|
)
|
||||||
|
|
||||||
fun makeGenerateAndFillMetadata(context: Context) =
|
fun makeGenerateAndFillMetadata(context: Context) =
|
||||||
DatasetMetadata(
|
DatasetMetadata(
|
||||||
|
@ -85,7 +93,11 @@ fun makeGenerateAndFillMetadata(context: Context) =
|
||||||
)
|
)
|
||||||
|
|
||||||
fun makeFillOtpFromSmsMetadata(context: Context) =
|
fun makeFillOtpFromSmsMetadata(context: Context) =
|
||||||
DatasetMetadata(context.getString(R.string.oreo_autofill_fill_otp_from_sms), null, R.drawable.ic_autofill_sms)
|
DatasetMetadata(
|
||||||
|
context.getString(R.string.oreo_autofill_fill_otp_from_sms),
|
||||||
|
null,
|
||||||
|
R.drawable.ic_autofill_sms
|
||||||
|
)
|
||||||
|
|
||||||
fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
|
fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
|
||||||
|
|
||||||
|
|
|
@ -17,7 +17,8 @@ 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 { it.matches("[a-fA-F0-9]{16}".toRegex()) }
|
val maybeLongKeyId =
|
||||||
|
identifier.removePrefix("0x").takeIf { 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())
|
||||||
|
@ -25,7 +26,8 @@ sealed class GpgIdentifier {
|
||||||
|
|
||||||
// Match fingerprints:
|
// Match fingerprints:
|
||||||
// FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
|
// FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899
|
||||||
val maybeFingerprint = identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F0-9]{40}".toRegex()) }
|
val maybeFingerprint =
|
||||||
|
identifier.removePrefix("0x").takeIf { 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
|
// Truncating to the long key ID is not a security issue since OpenKeychain only
|
||||||
// accepts
|
// accepts
|
||||||
|
|
|
@ -41,7 +41,11 @@ fun <T : View> AlertDialog.requestInputFocusOnView(@IdRes id: Int) {
|
||||||
setOnShowListener {
|
setOnShowListener {
|
||||||
findViewById<T>(id)?.apply {
|
findViewById<T>(id)?.apply {
|
||||||
setOnFocusChangeListener { v, _ ->
|
setOnFocusChangeListener { v, _ ->
|
||||||
v.post { context.getSystemService<InputMethodManager>()?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT) }
|
v.post {
|
||||||
|
context
|
||||||
|
.getSystemService<InputMethodManager>()
|
||||||
|
?.showSoftInput(v, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
requestFocus()
|
requestFocus()
|
||||||
}
|
}
|
||||||
|
@ -64,7 +68,8 @@ 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).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
val masterKeyAlias =
|
||||||
|
MasterKey.Builder(applicationContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
||||||
return EncryptedSharedPreferences.create(
|
return EncryptedSharedPreferences.create(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
fileName,
|
fileName,
|
||||||
|
|
|
@ -20,8 +20,10 @@ import kotlin.reflect.KProperty
|
||||||
* Imported from
|
* Imported from
|
||||||
* https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
|
* https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c
|
||||||
*/
|
*/
|
||||||
class FragmentViewBindingDelegate<T : ViewBinding>(val fragment: Fragment, val viewBindingFactory: (View) -> T) :
|
class FragmentViewBindingDelegate<T : ViewBinding>(
|
||||||
ReadOnlyProperty<Fragment, T> {
|
val fragment: Fragment,
|
||||||
|
val viewBindingFactory: (View) -> T
|
||||||
|
) : ReadOnlyProperty<Fragment, T> {
|
||||||
|
|
||||||
private var binding: T? = null
|
private var binding: T? = null
|
||||||
|
|
||||||
|
@ -51,7 +53,9 @@ class FragmentViewBindingDelegate<T : ViewBinding>(val fragment: Fragment, val v
|
||||||
|
|
||||||
val lifecycle = fragment.viewLifecycleOwner.lifecycle
|
val lifecycle = fragment.viewLifecycleOwner.lifecycle
|
||||||
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
|
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
|
||||||
throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.")
|
throw IllegalStateException(
|
||||||
|
"Should not attempt to get bindings when Fragment views are destroyed."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
|
return viewBindingFactory(thisRef.requireView()).also { this.binding = it }
|
||||||
|
@ -61,5 +65,6 @@ class FragmentViewBindingDelegate<T : ViewBinding>(val fragment: Fragment, val v
|
||||||
fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
|
fun <T : ViewBinding> Fragment.viewBinding(viewBindingFactory: (View) -> T) =
|
||||||
FragmentViewBindingDelegate(this, viewBindingFactory)
|
FragmentViewBindingDelegate(this, viewBindingFactory)
|
||||||
|
|
||||||
inline fun <T : ViewBinding> AppCompatActivity.viewBinding(crossinline bindingInflater: (LayoutInflater) -> T) =
|
inline fun <T : ViewBinding> AppCompatActivity.viewBinding(
|
||||||
lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) }
|
crossinline bindingInflater: (LayoutInflater) -> T
|
||||||
|
) = lazy(LazyThreadSafetyMode.NONE) { bindingInflater.invoke(layoutInflater) }
|
||||||
|
|
|
@ -14,7 +14,8 @@ import java.net.UnknownHostException
|
||||||
/**
|
/**
|
||||||
* Supertype for all Git-related [Exception] s that can be thrown by [GitCommandExecutor.execute].
|
* Supertype for all Git-related [Exception] s that can be thrown by [GitCommandExecutor.execute].
|
||||||
*/
|
*/
|
||||||
sealed class GitException(@StringRes res: Int, vararg fmt: String) : Exception(buildMessage(res, *fmt)) {
|
sealed class GitException(@StringRes res: Int, vararg fmt: String) :
|
||||||
|
Exception(buildMessage(res, *fmt)) {
|
||||||
|
|
||||||
override val message = super.message!!
|
override val message = super.message!!
|
||||||
|
|
||||||
|
|
|
@ -74,11 +74,13 @@ class GitCommandExecutor(
|
||||||
// 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) {
|
||||||
when (rru.status) {
|
when (rru.status) {
|
||||||
RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD -> throw PushException.NonFastForward
|
RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD ->
|
||||||
|
throw PushException.NonFastForward
|
||||||
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, -> throw PushException.Generic(rru.status.name)
|
RemoteRefUpdate.Status.NOT_ATTEMPTED, ->
|
||||||
|
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
|
||||||
|
|
|
@ -15,4 +15,9 @@ import java.util.Date
|
||||||
* @property authorName name of the commit's author without email address.
|
* @property authorName name of the commit's author without email address.
|
||||||
* @property time time when the commit was created.
|
* @property time time when the commit was created.
|
||||||
*/
|
*/
|
||||||
data class GitCommit(val hash: String, val shortMessage: String, val authorName: String, val time: Date)
|
data class GitCommit(
|
||||||
|
val hash: String,
|
||||||
|
val shortMessage: String,
|
||||||
|
val authorName: String,
|
||||||
|
val time: Date
|
||||||
|
)
|
||||||
|
|
|
@ -40,7 +40,9 @@ 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 { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }.toMutableList()
|
commits()
|
||||||
|
.map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) }
|
||||||
|
.toMutableList()
|
||||||
}
|
}
|
||||||
val size = cache.size
|
val size = cache.size
|
||||||
|
|
||||||
|
|
|
@ -11,13 +11,17 @@ import org.eclipse.jgit.api.RebaseCommand
|
||||||
import org.eclipse.jgit.api.ResetCommand
|
import org.eclipse.jgit.api.ResetCommand
|
||||||
import org.eclipse.jgit.lib.RepositoryState
|
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 =
|
private val resetCommands =
|
||||||
arrayOf(
|
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
|
||||||
git.push().setRemote("origin"),
|
git.push().setRemote("origin"),
|
||||||
// switch back to ${gitBranch}
|
// switch back to ${gitBranch}
|
||||||
|
@ -47,8 +51,12 @@ class BreakOutOfDetached(callingActivity: ContinuationContainerActivity) : GitOp
|
||||||
if (!git.repository.repositoryState.isRebasing && !merging) {
|
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(
|
||||||
.setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ -> callingActivity.finish() }
|
callingActivity.resources.getString(R.string.git_break_out_of_detached_unneeded)
|
||||||
|
)
|
||||||
|
.setPositiveButton(callingActivity.resources.getString(R.string.dialog_ok)) { _, _ ->
|
||||||
|
callingActivity.finish()
|
||||||
|
}
|
||||||
.show()
|
.show()
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -14,7 +14,8 @@ import org.eclipse.jgit.api.GitCommand
|
||||||
* @param uri URL to clone the repository from
|
* @param uri URL to clone the repository from
|
||||||
* @param callingActivity the calling activity
|
* @param callingActivity the calling activity
|
||||||
*/
|
*/
|
||||||
class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) : GitOperation(callingActivity) {
|
class CloneOperation(callingActivity: ContinuationContainerActivity, uri: String) :
|
||||||
|
GitOperation(callingActivity) {
|
||||||
|
|
||||||
override val commands: Array<GitCommand<out Any>> =
|
override val commands: Array<GitCommand<out Any>> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
|
|
|
@ -24,7 +24,8 @@ 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(val callingActivity: FragmentActivity, val authMode: AuthMode) : InteractivePasswordFinder() {
|
class CredentialFinder(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()
|
||||||
|
@ -49,18 +50,22 @@ class CredentialFinder(val callingActivity: FragmentActivity, val authMode: Auth
|
||||||
rememberRes = R.string.git_operation_remember_password
|
rememberRes = R.string.git_operation_remember_password
|
||||||
errorRes = R.string.git_operation_wrong_password
|
errorRes = R.string.git_operation_wrong_password
|
||||||
}
|
}
|
||||||
else -> throw IllegalStateException("Only SshKey and Password connection mode ask for passwords")
|
else ->
|
||||||
|
throw IllegalStateException("Only SshKey and Password connection mode ask for passwords")
|
||||||
}
|
}
|
||||||
if (isRetry) gitOperationPrefs.edit { remove(credentialPref) }
|
if (isRetry) 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") val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
|
@SuppressLint("InflateParams")
|
||||||
val credentialLayout = dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
|
val dialogView = layoutInflater.inflate(R.layout.git_credential_layout, null)
|
||||||
|
val credentialLayout =
|
||||||
|
dialogView.findViewById<TextInputLayout>(R.id.git_auth_passphrase_layout)
|
||||||
val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
|
val editCredential = dialogView.findViewById<TextInputEditText>(R.id.git_auth_credential)
|
||||||
editCredential.setHint(hintRes)
|
editCredential.setHint(hintRes)
|
||||||
val rememberCredential = dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
|
val rememberCredential =
|
||||||
|
dialogView.findViewById<MaterialCheckBox>(R.id.git_auth_remember_credential)
|
||||||
rememberCredential.setText(rememberRes)
|
rememberCredential.setText(rememberRes)
|
||||||
if (isRetry) {
|
if (isRetry) {
|
||||||
credentialLayout.error = callingActivity.resources.getString(errorRes)
|
credentialLayout.error = callingActivity.resources.getString(errorRes)
|
||||||
|
|
|
@ -60,7 +60,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
||||||
private val authActivity
|
private val authActivity
|
||||||
get() = callingActivity as ContinuationContainerActivity
|
get() = callingActivity as ContinuationContainerActivity
|
||||||
|
|
||||||
private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) : CredentialsProvider() {
|
private class HttpsCredentialsProvider(private val passwordFinder: PasswordFinder) :
|
||||||
|
CredentialsProvider() {
|
||||||
|
|
||||||
private var cachedPassword: CharArray? = null
|
private var cachedPassword: CharArray? = null
|
||||||
|
|
||||||
|
@ -72,7 +73,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
||||||
is CredentialItem.Username -> item.value = uri?.user
|
is CredentialItem.Username -> item.value = uri?.user
|
||||||
is CredentialItem.Password -> {
|
is CredentialItem.Password -> {
|
||||||
item.value =
|
item.value =
|
||||||
cachedPassword?.clone() ?: passwordFinder.reqPassword(null).also { cachedPassword = it.clone() }
|
cachedPassword?.clone()
|
||||||
|
?: passwordFinder.reqPassword(null).also { cachedPassword = it.clone() }
|
||||||
}
|
}
|
||||||
else -> UnsupportedCredentialItem(uri, item.javaClass.name)
|
else -> UnsupportedCredentialItem(uri, item.javaClass.name)
|
||||||
}
|
}
|
||||||
|
@ -102,7 +104,10 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
||||||
.onFailure { e -> e(e) }
|
.onFailure { e -> e(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun registerAuthProviders(authMethod: SshAuthMethod, credentialsProvider: CredentialsProvider? = null) {
|
private fun registerAuthProviders(
|
||||||
|
authMethod: SshAuthMethod,
|
||||||
|
credentialsProvider: CredentialsProvider? = null
|
||||||
|
) {
|
||||||
sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
|
sshSessionFactory = SshjSessionFactory(authMethod, hostKeyFile)
|
||||||
commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
|
commands.filterIsInstance<TransportCommand<*, *>>().forEach { command ->
|
||||||
command.setTransportConfigCallback { transport: Transport ->
|
command.setTransportConfigCallback { transport: Transport ->
|
||||||
|
@ -132,12 +137,12 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
||||||
MaterialAlertDialogBuilder(callingActivity)
|
MaterialAlertDialogBuilder(callingActivity)
|
||||||
.setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
|
.setMessage(callingActivity.resources.getString(R.string.ssh_preferences_dialog_text))
|
||||||
.setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
|
.setTitle(callingActivity.resources.getString(R.string.ssh_preferences_dialog_title))
|
||||||
.setPositiveButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)) { _, _ ->
|
.setPositiveButton(
|
||||||
getSshKey(false)
|
callingActivity.resources.getString(R.string.ssh_preferences_dialog_import)
|
||||||
}
|
) { _, _ -> getSshKey(false) }
|
||||||
.setNegativeButton(callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)) { _, _ ->
|
.setNegativeButton(
|
||||||
getSshKey(true)
|
callingActivity.resources.getString(R.string.ssh_preferences_dialog_generate)
|
||||||
}
|
) { _, _ -> getSshKey(true) }
|
||||||
.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()
|
||||||
|
@ -153,9 +158,10 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
||||||
val result =
|
val result =
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
|
suspendCoroutine<BiometricAuthenticator.Result> { cont ->
|
||||||
BiometricAuthenticator.authenticate(callingActivity, R.string.biometric_prompt_title_ssh_auth) {
|
BiometricAuthenticator.authenticate(
|
||||||
if (it !is BiometricAuthenticator.Result.Failure) cont.resume(it)
|
callingActivity,
|
||||||
}
|
R.string.biometric_prompt_title_ssh_auth
|
||||||
|
) { if (it !is BiometricAuthenticator.Result.Failure) cont.resume(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
|
@ -193,7 +199,8 @@ abstract class GitOperation(protected val callingActivity: FragmentActivity) {
|
||||||
}
|
}
|
||||||
AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity))
|
AuthMode.OpenKeychain -> registerAuthProviders(SshAuthMethod.OpenKeychain(authActivity))
|
||||||
AuthMode.Password -> {
|
AuthMode.Password -> {
|
||||||
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 -> {}
|
||||||
|
|
|
@ -7,7 +7,8 @@ package dev.msfjarvis.aps.util.git.operation
|
||||||
import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
|
import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
|
||||||
import org.eclipse.jgit.api.GitCommand
|
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>> =
|
override val commands: Array<GitCommand<out Any>> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
|
|
|
@ -7,7 +7,8 @@ package dev.msfjarvis.aps.util.git.operation
|
||||||
import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
|
import dev.msfjarvis.aps.util.git.sshj.ContinuationContainerActivity
|
||||||
import org.eclipse.jgit.api.ResetCommand
|
import org.eclipse.jgit.api.ResetCommand
|
||||||
|
|
||||||
class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) : GitOperation(callingActivity) {
|
class ResetToRemoteOperation(callingActivity: ContinuationContainerActivity) :
|
||||||
|
GitOperation(callingActivity) {
|
||||||
|
|
||||||
override val commands =
|
override val commands =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
|
|
|
@ -106,7 +106,8 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
|
||||||
val response = sshPublicKeyResponse.response as SshPublicKeyResponse
|
val response = sshPublicKeyResponse.response as SshPublicKeyResponse
|
||||||
val sshPublicKey = response.sshPublicKey!!
|
val sshPublicKey = response.sshPublicKey!!
|
||||||
publicKey =
|
publicKey =
|
||||||
parseSshPublicKey(sshPublicKey) ?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
|
parseSshPublicKey(sshPublicKey)
|
||||||
|
?: throw IllegalStateException("OpenKeychain API returned invalid SSH key")
|
||||||
}
|
}
|
||||||
is ApiResponse.NoSuchKey ->
|
is ApiResponse.NoSuchKey ->
|
||||||
if (isRetry) {
|
if (isRetry) {
|
||||||
|
@ -122,13 +123,17 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
|
||||||
|
|
||||||
private suspend fun selectKey() {
|
private suspend fun selectKey() {
|
||||||
when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) {
|
when (val keySelectionResponse = executeApiRequest(KeySelectionRequest())) {
|
||||||
is ApiResponse.Success -> keyId = (keySelectionResponse.response as KeySelectionResponse).keyId
|
is ApiResponse.Success ->
|
||||||
|
keyId = (keySelectionResponse.response as KeySelectionResponse).keyId
|
||||||
is ApiResponse.GeneralError -> throw keySelectionResponse.exception
|
is ApiResponse.GeneralError -> throw keySelectionResponse.exception
|
||||||
is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception
|
is ApiResponse.NoSuchKey -> throw keySelectionResponse.exception
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 =
|
val result =
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
@ -141,7 +146,11 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
ApiResponse.Success(
|
||||||
when (request) {
|
when (request) {
|
||||||
|
@ -153,20 +162,27 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
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 =
|
val resultOfUserInteraction: Intent =
|
||||||
withContext(Dispatchers.Main) {
|
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()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
executeApiRequest(request, resultOfUserInteraction)
|
executeApiRequest(request, resultOfUserInteraction)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val error = result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
|
val error =
|
||||||
|
result.getParcelableExtra<SshAuthenticationApiError>(SshAuthenticationApi.EXTRA_ERROR)
|
||||||
val exception =
|
val exception =
|
||||||
UserAuthException(DisconnectReason.UNKNOWN, "Request ${request::class.simpleName} failed: ${error?.message}")
|
UserAuthException(
|
||||||
|
DisconnectReason.UNKNOWN,
|
||||||
|
"Request ${request::class.simpleName} failed: ${error?.message}"
|
||||||
|
)
|
||||||
when (error?.error) {
|
when (error?.error) {
|
||||||
SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY ->
|
SshAuthenticationApiError.NO_AUTH_KEY, SshAuthenticationApiError.NO_SUCH_KEY ->
|
||||||
ApiResponse.NoSuchKey(exception)
|
ApiResponse.NoSuchKey(exception)
|
||||||
|
@ -181,7 +197,9 @@ class OpenKeychainKeyProvider private constructor(val activity: ContinuationCont
|
||||||
privateKey =
|
privateKey =
|
||||||
object : OpenKeychainPrivateKey {
|
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
|
||||||
is ApiResponse.GeneralError -> throw signingResponse.exception
|
is ApiResponse.GeneralError -> throw signingResponse.exception
|
||||||
is ApiResponse.NoSuchKey -> throw signingResponse.exception
|
is ApiResponse.NoSuchKey -> throw signingResponse.exception
|
||||||
|
|
|
@ -28,7 +28,8 @@ class OpenKeychainWrappedKeyAlgorithmFactory(private val factory: Factory.Named<
|
||||||
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 =
|
private val hashAlgorithm =
|
||||||
when (keyAlgorithm.keyAlgorithm) {
|
when (keyAlgorithm.keyAlgorithm) {
|
||||||
|
@ -39,11 +40,14 @@ class OpenKeychainWrappedKeyAlgorithm(private val keyAlgorithm: KeyAlgorithm) :
|
||||||
else -> SshAuthenticationApi.SHA512
|
else -> SshAuthenticationApi.SHA512
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun newSignature() = OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
|
override fun newSignature() =
|
||||||
|
OpenKeychainWrappedSignature(keyAlgorithm.newSignature(), hashAlgorithm)
|
||||||
}
|
}
|
||||||
|
|
||||||
class OpenKeychainWrappedSignature(private val wrappedSignature: Signature, private val hashAlgorithm: Int) :
|
class OpenKeychainWrappedSignature(
|
||||||
Signature by wrappedSignature {
|
private val wrappedSignature: Signature,
|
||||||
|
private val hashAlgorithm: Int
|
||||||
|
) : Signature by wrappedSignature {
|
||||||
|
|
||||||
private val data = ByteArrayOutputStream()
|
private val data = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
|
|
@ -115,7 +115,8 @@ 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 { putString(PreferenceKeys.GIT_REMOTE_KEY_TYPE, value?.value) }
|
set(value) =
|
||||||
|
context.sharedPrefs.edit { 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)
|
||||||
|
@ -138,13 +139,20 @@ 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(
|
Rsa(
|
||||||
KeyProperties.KEY_ALGORITHM_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(
|
Ecdsa(
|
||||||
|
@ -163,7 +171,9 @@ object SshKey {
|
||||||
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 { clear() }
|
context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE).edit {
|
||||||
|
clear()
|
||||||
|
}
|
||||||
if (privateKeyFile.isFile) {
|
if (privateKeyFile.isFile) {
|
||||||
privateKeyFile.delete()
|
privateKeyFile.delete()
|
||||||
}
|
}
|
||||||
|
@ -177,7 +187,8 @@ object SshKey {
|
||||||
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 =
|
val fileSize =
|
||||||
context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.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)
|
||||||
|
@ -186,7 +197,9 @@ object SshKey {
|
||||||
|
|
||||||
// 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 =
|
val sshKeyInputStream =
|
||||||
context.contentResolver.openInputStream(uri)
|
context.contentResolver.openInputStream(uri)
|
||||||
|
@ -199,7 +212,9 @@ object SshKey {
|
||||||
!Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
|
!Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) ||
|
||||||
!Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
|
!Regex("END .* PRIVATE KEY").containsMatchIn(lines.last())
|
||||||
)
|
)
|
||||||
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)
|
||||||
|
)
|
||||||
|
|
||||||
// At this point, we are reasonably confident that we have actually been provided a private
|
// At this point, we are reasonably confident that we have actually been provided a private
|
||||||
// key and delete the old key.
|
// key and delete the old key.
|
||||||
|
@ -249,7 +264,9 @@ object SshKey {
|
||||||
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 -> os.write((keyPair.private as EdDSAPrivateKey).seed) }
|
encryptedPrivateKeyFile.openFileOutput().use { os ->
|
||||||
|
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))
|
||||||
|
@ -288,7 +305,8 @@ object SshKey {
|
||||||
|
|
||||||
fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? =
|
fun provide(client: SSHClient, passphraseFinder: InteractivePasswordFinder): KeyProvider? =
|
||||||
when (type) {
|
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
|
||||||
null -> null
|
null -> null
|
||||||
|
@ -305,7 +323,10 @@ object SshKey {
|
||||||
override fun getPrivate(): PrivateKey =
|
override fun getPrivate(): PrivateKey =
|
||||||
runCatching { androidKeystore.sshPrivateKey!! }.getOrElse { error ->
|
runCatching { androidKeystore.sshPrivateKey!! }.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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getType(): KeyType = KeyType.fromKey(public)
|
override fun getType(): KeyType = KeyType.fromKey(public)
|
||||||
|
@ -326,7 +347,9 @@ object SshKey {
|
||||||
// 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 { getOrCreateWrappedPrivateKeyFile(false) }
|
val encryptedPrivateKeyFile = runBlocking { 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)
|
||||||
|
|
|
@ -37,7 +37,8 @@ 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 { it.name == BouncyCastleProvider.PROVIDER_NAME }
|
val bcIndex =
|
||||||
|
Security.getProviders().indexOfFirst { 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())
|
||||||
|
@ -77,9 +78,11 @@ 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?) = trace(format, arg1, arg2)
|
override fun trace(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
||||||
|
trace(format, arg1, arg2)
|
||||||
|
|
||||||
override fun trace(marker: Marker?, format: String, vararg arguments: Any?) = trace(format, *arguments)
|
override fun trace(marker: Marker?, format: String, vararg arguments: Any?) =
|
||||||
|
trace(format, *arguments)
|
||||||
|
|
||||||
override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t)
|
override fun trace(marker: Marker?, msg: String, t: Throwable?) = trace(msg, t)
|
||||||
|
|
||||||
|
@ -90,9 +93,11 @@ 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?) = debug(format, arg1, arg2)
|
override fun debug(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
||||||
|
debug(format, arg1, arg2)
|
||||||
|
|
||||||
override fun debug(marker: Marker?, format: String, vararg arguments: Any?) = debug(format, *arguments)
|
override fun debug(marker: Marker?, format: String, vararg arguments: Any?) =
|
||||||
|
debug(format, *arguments)
|
||||||
|
|
||||||
override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t)
|
override fun debug(marker: Marker?, msg: String, t: Throwable?) = debug(msg, t)
|
||||||
|
|
||||||
|
@ -103,9 +108,11 @@ 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?) = info(format, arg1, arg2)
|
override fun info(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
||||||
|
info(format, arg1, arg2)
|
||||||
|
|
||||||
override fun info(marker: Marker?, format: String, vararg arguments: Any?) = info(format, *arguments)
|
override fun info(marker: Marker?, format: String, vararg arguments: Any?) =
|
||||||
|
info(format, *arguments)
|
||||||
|
|
||||||
override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t)
|
override fun info(marker: Marker?, msg: String, t: Throwable?) = info(msg, t)
|
||||||
|
|
||||||
|
@ -116,9 +123,11 @@ 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?) = warn(format, arg1, arg2)
|
override fun warn(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
||||||
|
warn(format, arg1, arg2)
|
||||||
|
|
||||||
override fun warn(marker: Marker?, format: String, vararg arguments: Any?) = warn(format, *arguments)
|
override fun warn(marker: Marker?, format: String, vararg arguments: Any?) =
|
||||||
|
warn(format, *arguments)
|
||||||
|
|
||||||
override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t)
|
override fun warn(marker: Marker?, msg: String, t: Throwable?) = warn(msg, t)
|
||||||
|
|
||||||
|
@ -129,9 +138,11 @@ 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?) = error(format, arg1, arg2)
|
override fun error(marker: Marker?, format: String, arg1: Any?, arg2: Any?) =
|
||||||
|
error(format, arg1, arg2)
|
||||||
|
|
||||||
override fun error(marker: Marker?, format: String, vararg arguments: Any?) = error(format, *arguments)
|
override fun error(marker: Marker?, format: String, vararg arguments: Any?) =
|
||||||
|
error(format, *arguments)
|
||||||
|
|
||||||
override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t)
|
override fun error(marker: Marker?, msg: String, t: Throwable?) = error(msg, t)
|
||||||
}
|
}
|
||||||
|
@ -148,7 +159,8 @@ 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") private fun String.fix() = replace("""(?!<\\)\{\}""".toRegex(), "%s")
|
@Suppress("RegExpRedundantEscape")
|
||||||
|
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)
|
||||||
|
|
|
@ -52,7 +52,10 @@ 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) { suspendCoroutine<String?> { cont -> askForPassword(cont, isRetry) } }
|
val password =
|
||||||
|
runBlocking(Dispatchers.Main) {
|
||||||
|
suspendCoroutine<String?> { cont -> askForPassword(cont, isRetry) }
|
||||||
|
}
|
||||||
isRetry = true
|
isRetry = true
|
||||||
return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
|
return password?.toCharArray() ?: throw SSHException(DisconnectReason.AUTH_CANCELLED_BY_USER)
|
||||||
}
|
}
|
||||||
|
@ -60,11 +63,17 @@ abstract class InteractivePasswordFinder : PasswordFinder {
|
||||||
final override fun shouldRetry(resource: Resource<*>?) = true
|
final override fun shouldRetry(resource: Resource<*>?) = true
|
||||||
}
|
}
|
||||||
|
|
||||||
class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) : SshSessionFactory() {
|
class SshjSessionFactory(private val authMethod: SshAuthMethod, private val hostKeyFile: File) :
|
||||||
|
SshSessionFactory() {
|
||||||
|
|
||||||
private var currentSession: SshjSession? = null
|
private var currentSession: SshjSession? = null
|
||||||
|
|
||||||
override fun getSession(uri: URIish, credentialsProvider: CredentialsProvider?, fs: FS?, tms: Int): RemoteSession {
|
override fun getSession(
|
||||||
|
uri: URIish,
|
||||||
|
credentialsProvider: CredentialsProvider?,
|
||||||
|
fs: FS?,
|
||||||
|
tms: Int
|
||||||
|
): RemoteSession {
|
||||||
return currentSession
|
return currentSession
|
||||||
?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
|
?: SshjSession(uri, uri.user, authMethod, hostKeyFile).connect().also {
|
||||||
d { "New SSH connection created" }
|
d { "New SSH connection created" }
|
||||||
|
@ -81,7 +90,9 @@ private fun makeTofuHostKeyVerifier(hostKeyFile: File): HostKeyVerifier {
|
||||||
if (!hostKeyFile.exists()) {
|
if (!hostKeyFile.exists()) {
|
||||||
return HostKeyVerifier { _, _, key ->
|
return HostKeyVerifier { _, _, key ->
|
||||||
val digest =
|
val digest =
|
||||||
runCatching { SecurityUtils.getMessageDigest("SHA-256") }.getOrElse { e -> throw SSHRuntimeException(e) }
|
runCatching { SecurityUtils.getMessageDigest("SHA-256") }.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)}"
|
||||||
|
@ -115,7 +126,9 @@ private class SshjSession(
|
||||||
val userPlusHost = "${uri.user}@${uri.host}"
|
val userPlusHost = "${uri.user}@${uri.host}"
|
||||||
val realUser = userPlusHost.substringBeforeLast('@')
|
val realUser = userPlusHost.substringBeforeLast('@')
|
||||||
val realHost = userPlusHost.substringAfterLast('@')
|
val realHost = userPlusHost.substringAfterLast('@')
|
||||||
uri.setUser(realUser).setHost(realHost).also { d { "After fixup: user=${it.user}, host=${it.host}" } }
|
uri.setUser(realUser).setHost(realHost).also {
|
||||||
|
d { "After fixup: user=${it.user}, host=${it.host}" }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
uri
|
uri
|
||||||
}
|
}
|
||||||
|
@ -131,7 +144,8 @@ private class SshjSession(
|
||||||
ssh.auth(username, passwordAuth)
|
ssh.auth(username, passwordAuth)
|
||||||
}
|
}
|
||||||
is SshAuthMethod.SshKey -> {
|
is SshAuthMethod.SshKey -> {
|
||||||
val pubkeyAuth = AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
|
val pubkeyAuth =
|
||||||
|
AuthPublickey(SshKey.provide(ssh, CredentialFinder(authMethod.activity, AuthMode.SshKey)))
|
||||||
ssh.auth(username, pubkeyAuth, passwordAuth)
|
ssh.auth(username, pubkeyAuth, passwordAuth)
|
||||||
}
|
}
|
||||||
is SshAuthMethod.OpenKeychain -> {
|
is SshAuthMethod.OpenKeychain -> {
|
||||||
|
@ -174,7 +188,8 @@ private class SshjSession(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class SshjProcess(private val command: Session.Command, private val timeout: Long) : Process() {
|
private class SshjProcess(private val command: Session.Command, private val timeout: Long) :
|
||||||
|
Process() {
|
||||||
|
|
||||||
override fun waitFor(): Int {
|
override fun waitFor(): Int {
|
||||||
command.join(timeout, TimeUnit.SECONDS)
|
command.join(timeout, TimeUnit.SECONDS)
|
||||||
|
|
|
@ -42,7 +42,10 @@ object PasswordGenerator {
|
||||||
*/
|
*/
|
||||||
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()) putBoolean(possibleOption.key, possibleOption in options)
|
for (possibleOption in PasswordOption.values()) putBoolean(
|
||||||
|
possibleOption.key,
|
||||||
|
possibleOption in options
|
||||||
|
)
|
||||||
putInt("length", targetLength)
|
putInt("length", targetLength)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
@ -82,7 +85,9 @@ 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.NoUppercaseLetters, PasswordOption.NoLowercaseLetters -> {
|
PasswordOption.NoDigits,
|
||||||
|
PasswordOption.NoUppercaseLetters,
|
||||||
|
PasswordOption.NoLowercaseLetters -> {
|
||||||
numCharacterCategories++
|
numCharacterCategories++
|
||||||
}
|
}
|
||||||
PasswordOption.NoAmbiguousCharacters,
|
PasswordOption.NoAmbiguousCharacters,
|
||||||
|
@ -98,7 +103,9 @@ object PasswordGenerator {
|
||||||
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error))
|
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_no_chars_error))
|
||||||
}
|
}
|
||||||
if (length < numCharacterCategories) {
|
if (length < numCharacterCategories) {
|
||||||
throw PasswordGeneratorException(ctx.resources.getString(R.string.pwgen_length_too_short_error))
|
throw PasswordGeneratorException(
|
||||||
|
ctx.resources.getString(R.string.pwgen_length_too_short_error)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) {
|
if (!(pwgenFlags hasFlag UPPERS) && !(pwgenFlags hasFlag LOWERS)) {
|
||||||
phonemes = false
|
phonemes = false
|
||||||
|
@ -114,7 +121,9 @@ object PasswordGenerator {
|
||||||
var iterations = 0
|
var iterations = 0
|
||||||
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 =
|
password =
|
||||||
if (phonemes) {
|
if (phonemes) {
|
||||||
RandomPhonemesGenerator.generate(length, pwgenFlags)
|
RandomPhonemesGenerator.generate(length, pwgenFlags)
|
||||||
|
|
|
@ -36,7 +36,9 @@ object RandomPasswordGenerator {
|
||||||
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 && candidate in PasswordGenerator.AMBIGUOUS_STR) {
|
if (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
|
||||||
|
candidate in PasswordGenerator.AMBIGUOUS_STR
|
||||||
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
password += candidate
|
password += candidate
|
||||||
|
|
|
@ -100,7 +100,9 @@ object RandomPhonemesGenerator {
|
||||||
if (!candidate.flags.hasFlag(nextBasicType) ||
|
if (!candidate.flags.hasFlag(nextBasicType) ||
|
||||||
(isStartOfPart && candidate.flags hasFlag NOT_FIRST) ||
|
(isStartOfPart && candidate.flags hasFlag NOT_FIRST) ||
|
||||||
// Don't let a diphthong that starts with a vowel follow a vowel.
|
// Don't let a diphthong that starts with a vowel follow a vowel.
|
||||||
(previousFlags hasFlag VOWEL && candidate.flags hasFlag VOWEL && candidate.flags hasFlag DIPHTHONG) ||
|
(previousFlags hasFlag VOWEL &&
|
||||||
|
candidate.flags hasFlag VOWEL &&
|
||||||
|
candidate.flags hasFlag DIPHTHONG) ||
|
||||||
// Don't add multi-character candidates if we would go over the targetLength.
|
// Don't add multi-character candidates if we would go over the targetLength.
|
||||||
(password.length + candidate.length > targetLength) ||
|
(password.length + candidate.length > targetLength) ||
|
||||||
(pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)
|
(pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS && candidate.isAmbiguous)
|
||||||
|
@ -129,11 +131,15 @@ object RandomPhonemesGenerator {
|
||||||
// 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 && secureRandomBiasedBoolean(30)) {
|
if (!isStartOfPart &&
|
||||||
|
pwFlags hasFlag PasswordGenerator.DIGITS &&
|
||||||
|
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 && randomDigit in PasswordGenerator.AMBIGUOUS_STR)
|
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
|
||||||
|
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.
|
||||||
|
@ -143,11 +149,15 @@ object RandomPhonemesGenerator {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isStartOfPart && pwFlags hasFlag PasswordGenerator.SYMBOLS && secureRandomBiasedBoolean(20)) {
|
if (!isStartOfPart &&
|
||||||
|
pwFlags hasFlag PasswordGenerator.SYMBOLS &&
|
||||||
|
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 && randomSymbol in PasswordGenerator.AMBIGUOUS_STR)
|
} while (pwFlags hasFlag PasswordGenerator.NO_AMBIGUOUS &&
|
||||||
|
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.
|
||||||
}
|
}
|
||||||
|
@ -157,8 +167,9 @@ object RandomPhonemesGenerator {
|
||||||
nextBasicType =
|
nextBasicType =
|
||||||
when {
|
when {
|
||||||
candidate.flags.hasFlag(CONSONANT) -> VOWEL
|
candidate.flags.hasFlag(CONSONANT) -> VOWEL
|
||||||
previousFlags.hasFlag(VOWEL) || candidate.flags.hasFlag(DIPHTHONG) || secureRandomBiasedBoolean(60) ->
|
previousFlags.hasFlag(VOWEL) ||
|
||||||
CONSONANT
|
candidate.flags.hasFlag(DIPHTHONG) ||
|
||||||
|
secureRandomBiasedBoolean(60) -> CONSONANT
|
||||||
else -> VOWEL
|
else -> VOWEL
|
||||||
}
|
}
|
||||||
previousFlags = candidate.flags
|
previousFlags = candidate.flags
|
||||||
|
|
|
@ -109,7 +109,9 @@ class PasswordBuilder(ctx: Context) {
|
||||||
}
|
}
|
||||||
else candidate
|
else candidate
|
||||||
CapsType.TitleCase ->
|
CapsType.TitleCase ->
|
||||||
candidate.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
candidate.replaceFirstChar {
|
||||||
|
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
|
||||||
|
}
|
||||||
CapsType.lowercase -> candidate.lowercase(Locale.getDefault())
|
CapsType.lowercase -> candidate.lowercase(Locale.getDefault())
|
||||||
CapsType.As_iS -> candidate
|
CapsType.As_iS -> candidate
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,10 @@ class XkpwdDictionary(context: Context) {
|
||||||
context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines()
|
context.resources.openRawResource(R.raw.xkpwdict).bufferedReader().readLines()
|
||||||
}
|
}
|
||||||
|
|
||||||
words = lines.asSequence().map { it.trim() }.filter { it.isNotEmpty() && !it.contains(' ') }.groupBy { it.length }
|
words =
|
||||||
|
lines.asSequence().map { it.trim() }.filter { it.isNotEmpty() && !it.contains(' ') }.groupBy {
|
||||||
|
it.length
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
|
@ -140,7 +140,10 @@ class ClipboardService : Service() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.N)
|
@RequiresApi(Build.VERSION_CODES.N)
|
||||||
private fun createNotificationApi24(pendingIntent: PendingIntent, clearTimeMs: Long): Notification {
|
private fun createNotificationApi24(
|
||||||
|
pendingIntent: PendingIntent,
|
||||||
|
clearTimeMs: Long
|
||||||
|
): Notification {
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle(getString(R.string.app_name))
|
.setContentTitle(getString(R.string.app_name))
|
||||||
.setContentText(getString(R.string.tap_clear_clipboard))
|
.setContentText(getString(R.string.tap_clear_clipboard))
|
||||||
|
@ -157,7 +160,11 @@ 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 =
|
val serviceChannel =
|
||||||
NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW)
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
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)
|
||||||
|
|
|
@ -60,7 +60,11 @@ class OreoAutofillService : AutofillService() {
|
||||||
cachePublicSuffixList(applicationContext)
|
cachePublicSuffixList(applicationContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFillRequest(request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback) {
|
override fun onFillRequest(
|
||||||
|
request: FillRequest,
|
||||||
|
cancellationSignal: CancellationSignal,
|
||||||
|
callback: FillCallback
|
||||||
|
) {
|
||||||
val structure =
|
val structure =
|
||||||
request.fillContexts.lastOrNull()?.structure
|
request.fillContexts.lastOrNull()?.structure
|
||||||
?: run {
|
?: run {
|
||||||
|
@ -93,7 +97,8 @@ class OreoAutofillService : AutofillService() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
Api30AutofillResponseBuilder(formToFill).fillCredentials(this, request.inlineSuggestionsRequest, callback)
|
Api30AutofillResponseBuilder(formToFill)
|
||||||
|
.fillCredentials(this, request.inlineSuggestionsRequest, callback)
|
||||||
} else {
|
} else {
|
||||||
AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
|
AutofillResponseBuilder(formToFill).fillCredentials(this, callback)
|
||||||
}
|
}
|
||||||
|
@ -148,11 +153,13 @@ 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)?.splitToSequence('\n')?.filter {
|
return sharedPrefs
|
||||||
it.isNotBlank() && it.first() != '.' && it.last() != '.'
|
.getString(PreferenceKeys.OREO_AUTOFILL_CUSTOM_PUBLIC_SUFFIXES)
|
||||||
}
|
?.splitToSequence('\n')
|
||||||
|
?.filter { it.isNotBlank() && it.first() != '.' && it.last() != '.' }
|
||||||
?: emptySequence()
|
?: emptySequence()
|
||||||
}
|
}
|
||||||
|
|
|
@ -137,7 +137,11 @@ 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 =
|
val serviceChannel =
|
||||||
NotificationChannel(CHANNEL_ID, getString(R.string.app_name), NotificationManager.IMPORTANCE_LOW)
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
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,7 +25,8 @@ 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] ?: throw IllegalArgumentException("$type is not a valid Protocol")
|
return map[type ?: return Ssh]
|
||||||
|
?: throw IllegalArgumentException("$type is not a valid Protocol")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -41,7 +42,8 @@ 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] ?: throw IllegalArgumentException("$type is not a valid AuthMode")
|
return map[type ?: return SshKey]
|
||||||
|
?: throw IllegalArgumentException("$type is not a valid AuthMode")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,12 +52,18 @@ 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) {
|
private val encryptedSettings by lazy(LazyThreadSafetyMode.PUBLICATION) {
|
||||||
Application.instance.getEncryptedGitPrefs()
|
Application.instance.getEncryptedGitPrefs()
|
||||||
}
|
}
|
||||||
private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) { Application.instance.getEncryptedProxyPrefs() }
|
private val proxySettings by lazy(LazyThreadSafetyMode.PUBLICATION) {
|
||||||
private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) { "${Application.instance.filesDir}/.host_key" }
|
Application.instance.getEncryptedProxyPrefs()
|
||||||
|
}
|
||||||
|
private val hostKeyPath by lazy(LazyThreadSafetyMode.NONE) {
|
||||||
|
"${Application.instance.filesDir}/.host_key"
|
||||||
|
}
|
||||||
|
|
||||||
var authMode
|
var authMode
|
||||||
get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
|
get() = AuthMode.fromString(settings.getString(PreferenceKeys.GIT_REMOTE_AUTH))
|
||||||
|
|
|
@ -60,7 +60,8 @@ private fun migrateToGitUrlBasedConfig(sharedPrefs: SharedPreferences) {
|
||||||
val url =
|
val url =
|
||||||
when {
|
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 { if (URI(url).rawAuthority != null) url else null }.get()
|
runCatching { if (URI(url).rawAuthority != null) url else null }.get()
|
||||||
|
@ -96,7 +97,10 @@ 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) && !SshKey.exists && privateKeyFile.exists()) {
|
if (sharedPrefs.contains(PreferenceKeys.USE_GENERATED_KEY) &&
|
||||||
|
!SshKey.exists &&
|
||||||
|
privateKeyFile.exists()
|
||||||
|
) {
|
||||||
// Currently uses a private key imported or generated with an old version of Password Store.
|
// 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.
|
||||||
|
|
|
@ -18,10 +18,15 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator<PasswordItem>)
|
||||||
(p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true)
|
(p1.type + p1.name).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 ->
|
||||||
|
p1.name.compareTo(p2.name, ignoreCase = true)
|
||||||
|
}
|
||||||
|
),
|
||||||
RECENTLY_USED(
|
RECENTLY_USED(
|
||||||
Comparator { p1: PasswordItem, p2: PasswordItem ->
|
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())
|
||||||
when {
|
when {
|
||||||
|
|
|
@ -44,7 +44,8 @@ class UriTotpFinder @Inject constructor() : 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]) && Uri.parse(line).getQueryParameter("algorithm") != null) {
|
if (line.startsWith(TOTP_FIELDS[0]) && Uri.parse(line).getQueryParameter("algorithm") != null
|
||||||
|
) {
|
||||||
return Uri.parse(line).getQueryParameter("algorithm")!!
|
return Uri.parse(line).getQueryParameter("algorithm")!!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -75,8 +75,14 @@ private fun PasswordItem.Companion.makeComparator(
|
||||||
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)) { directoryStructure.getIdentifierFor(it.file) })
|
.then(
|
||||||
.then(compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getUsernameFor(it.file) })
|
compareBy(nullsLast(CaseInsensitiveComparator)) {
|
||||||
|
directoryStructure.getIdentifierFor(it.file)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
compareBy(nullsLast(CaseInsensitiveComparator)) { directoryStructure.getUsernameFor(it.file) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val PasswordItem.stableId: String
|
val PasswordItem.stableId: String
|
||||||
|
@ -179,7 +185,8 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
|
||||||
.mapLatest { searchAction ->
|
.mapLatest { searchAction ->
|
||||||
val listResultFlow =
|
val listResultFlow =
|
||||||
when (searchAction.searchMode) {
|
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 =
|
val prefilteredResultFlow =
|
||||||
|
@ -188,9 +195,8 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
|
||||||
ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory }
|
ListMode.DirectoriesOnly -> listResultFlow.filter { it.isDirectory }
|
||||||
ListMode.AllEntries -> listResultFlow
|
ListMode.AllEntries -> listResultFlow
|
||||||
}
|
}
|
||||||
val filterModeToUse = if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode
|
|
||||||
val passwordList =
|
val passwordList =
|
||||||
when (filterModeToUse) {
|
when (if (searchAction.filter == "") FilterMode.NoFilter else searchAction.filterMode) {
|
||||||
FilterMode.NoFilter -> {
|
FilterMode.NoFilter -> {
|
||||||
prefilteredResultFlow.map { it.toPasswordItem() }.toList().sortedWith(itemComparator)
|
prefilteredResultFlow.map { it.toPasswordItem() }.toList().sortedWith(itemComparator)
|
||||||
}
|
}
|
||||||
|
@ -201,7 +207,9 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
|
||||||
val regex = generateStrictDomainRegex(searchAction.filter)
|
val regex = generateStrictDomainRegex(searchAction.filter)
|
||||||
if (regex != null) {
|
if (regex != null) {
|
||||||
prefilteredResultFlow
|
prefilteredResultFlow
|
||||||
.filter { absoluteFile -> regex.containsMatchIn(absoluteFile.relativeTo(root).path) }
|
.filter { absoluteFile ->
|
||||||
|
regex.containsMatchIn(absoluteFile.relativeTo(root).path)
|
||||||
|
}
|
||||||
.map { it.toPasswordItem() }
|
.map { it.toPasswordItem() }
|
||||||
.toList()
|
.toList()
|
||||||
.sortedWith(itemComparator)
|
.sortedWith(itemComparator)
|
||||||
|
@ -218,7 +226,9 @@ class SearchableRepositoryViewModel(application: Application) : AndroidViewModel
|
||||||
.filter { it.first > 0 }
|
.filter { it.first > 0 }
|
||||||
.toList()
|
.toList()
|
||||||
.sortedWith(
|
.sortedWith(
|
||||||
compareByDescending<Pair<Int, PasswordItem>> { it.first }.thenBy(itemComparator) { it.second }
|
compareByDescending<Pair<Int, PasswordItem>> { it.first }.thenBy(itemComparator) {
|
||||||
|
it.second
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.map { it.second }
|
.map { it.second }
|
||||||
}
|
}
|
||||||
|
@ -387,7 +397,9 @@ open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
|
||||||
addObserver(
|
addObserver(
|
||||||
object : SelectionTracker.SelectionObserver<String>() {
|
object : SelectionTracker.SelectionObserver<String>() {
|
||||||
override fun onSelectionChanged() {
|
override fun onSelectionChanged() {
|
||||||
this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(requireSelectionTracker().selection)
|
this@SearchableRepositoryAdapter.onSelectionChangedListener?.invoke(
|
||||||
|
requireSelectionTracker().selection
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -395,15 +407,23 @@ open class SearchableRepositoryAdapter<T : RecyclerView.ViewHolder>(
|
||||||
}
|
}
|
||||||
|
|
||||||
private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null
|
private var onItemClickedListener: ((holder: T, item: PasswordItem) -> Unit)? = null
|
||||||
open fun onItemClicked(listener: (holder: T, item: PasswordItem) -> Unit): SearchableRepositoryAdapter<T> {
|
open fun onItemClicked(
|
||||||
check(onItemClickedListener == null) { "Only a single listener can be registered for onItemClicked" }
|
listener: (holder: T, item: PasswordItem) -> Unit
|
||||||
|
): SearchableRepositoryAdapter<T> {
|
||||||
|
check(onItemClickedListener == null) {
|
||||||
|
"Only a single listener can be registered for onItemClicked"
|
||||||
|
}
|
||||||
onItemClickedListener = listener
|
onItemClickedListener = listener
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null
|
private var onSelectionChangedListener: ((selection: Selection<String>) -> Unit)? = null
|
||||||
open fun onSelectionChanged(listener: (selection: Selection<String>) -> Unit): SearchableRepositoryAdapter<T> {
|
open fun onSelectionChanged(
|
||||||
check(onSelectionChangedListener == null) { "Only a single listener can be registered for onSelectionChanged" }
|
listener: (selection: Selection<String>) -> Unit
|
||||||
|
): SearchableRepositoryAdapter<T> {
|
||||||
|
check(onSelectionChangedListener == null) {
|
||||||
|
"Only a single listener can be registered for onSelectionChanged"
|
||||||
|
}
|
||||||
onSelectionChangedListener = listener
|
onSelectionChangedListener = listener
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,4 +96,3 @@
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
|
|
@ -77,7 +77,12 @@ 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(context, fillOtpFromSmsRequestCode++, intent, PendingIntent.FLAG_CANCEL_CURRENT)
|
return PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
fillOtpFromSmsRequestCode++,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_CANCEL_CURRENT
|
||||||
|
)
|
||||||
.intentSender
|
.intentSender
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -122,15 +127,17 @@ class AutofillSmsActivity : AppCompatActivity() {
|
||||||
|
|
||||||
private suspend fun waitForSms() {
|
private suspend fun waitForSms() {
|
||||||
val smsClient = SmsCodeRetriever.getAutofillClient(this@AutofillSmsActivity)
|
val smsClient = SmsCodeRetriever.getAutofillClient(this@AutofillSmsActivity)
|
||||||
runCatching { withContext(Dispatchers.IO) { smsClient.startSmsCodeRetriever().suspendableAwait() } }.onFailure { e
|
runCatching {
|
||||||
->
|
withContext(Dispatchers.IO) { smsClient.startSmsCodeRetriever().suspendableAwait() }
|
||||||
if (e is ResolvableApiException) {
|
|
||||||
e.startResolutionForResult(this@AutofillSmsActivity, 1)
|
|
||||||
} else {
|
|
||||||
e(e)
|
|
||||||
withContext(Dispatchers.Main) { finish() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
if (e is ResolvableApiException) {
|
||||||
|
e.startResolutionForResult(this@AutofillSmsActivity, 1)
|
||||||
|
} else {
|
||||||
|
e(e)
|
||||||
|
withContext(Dispatchers.Main) { finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val smsCodeRetrievedReceiver =
|
private val smsCodeRetrievedReceiver =
|
||||||
|
@ -144,7 +151,10 @@ class AutofillSmsActivity : AppCompatActivity() {
|
||||||
clientState,
|
clientState,
|
||||||
AutofillAction.FillOtpFromSms
|
AutofillAction.FillOtpFromSms
|
||||||
)
|
)
|
||||||
setResult(RESULT_OK, Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) })
|
setResult(
|
||||||
|
RESULT_OK,
|
||||||
|
Intent().apply { putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, fillInDataset) }
|
||||||
|
)
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,8 @@ public sealed class FormOrigin(public open val identifier: String) {
|
||||||
when (this) {
|
when (this) {
|
||||||
is Web -> identifier
|
is Web -> identifier
|
||||||
is App -> {
|
is App -> {
|
||||||
val info = context.packageManager.getApplicationInfo(identifier, PackageManager.GET_META_DATA)
|
val info =
|
||||||
|
context.packageManager.getApplicationInfo(identifier, PackageManager.GET_META_DATA)
|
||||||
val label = context.packageManager.getApplicationLabel(info)
|
val label = context.packageManager.getApplicationLabel(info)
|
||||||
if (untrusted) "“$label”" else "$label"
|
if (untrusted) "“$label”" else "$label"
|
||||||
}
|
}
|
||||||
|
@ -174,7 +175,10 @@ private class AutofillFormParser(
|
||||||
// the single origin among the detected fillable or saveable fields. If this origin
|
// the single origin among the detected fillable or saveable fields. If this origin
|
||||||
// is null, but we encountered web origins elsewhere in the AssistStructure, the
|
// is null, but we encountered web origins elsewhere in the AssistStructure, the
|
||||||
// situation is uncertain and Autofill should not be offered.
|
// situation is uncertain and Autofill should not be offered.
|
||||||
webOriginToFormOrigin(context, scenario.allFields.map { it.webOrigin }.toSet().singleOrNull() ?: return null)
|
webOriginToFormOrigin(
|
||||||
|
context,
|
||||||
|
scenario.allFields.map { it.webOrigin }.toSet().singleOrNull() ?: return null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -204,7 +208,12 @@ private constructor(
|
||||||
): FillableForm? {
|
): FillableForm? {
|
||||||
val form = AutofillFormParser(context, structure, isManualRequest, customSuffixes)
|
val form = AutofillFormParser(context, structure, isManualRequest, customSuffixes)
|
||||||
if (form.formOrigin == null || form.scenario == null) return null
|
if (form.formOrigin == null || form.scenario == null) return null
|
||||||
return FillableForm(form.formOrigin, form.scenario.map { it.autofillId }, form.ignoredIds, form.saveFlags)
|
return FillableForm(
|
||||||
|
form.formOrigin,
|
||||||
|
form.scenario.map { it.autofillId },
|
||||||
|
form.ignoredIds,
|
||||||
|
form.saveFlags
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -49,14 +49,19 @@ public fun computeCertificatesHash(context: Context, appPackage: String): String
|
||||||
// hashes comparable between versions and hence default to using the deprecated API.
|
// hashes comparable between versions and hence default to using the deprecated API.
|
||||||
@SuppressLint("PackageManagerGetSignatures")
|
@SuppressLint("PackageManagerGetSignatures")
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val signaturesOld = context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNATURES).signatures
|
val signaturesOld =
|
||||||
|
context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNATURES).signatures
|
||||||
val stableHashOld = stableHash(signaturesOld.map { it.toByteArray() })
|
val stableHashOld = stableHash(signaturesOld.map { it.toByteArray() })
|
||||||
if (Build.VERSION.SDK_INT >= 28) {
|
if (Build.VERSION.SDK_INT >= 28) {
|
||||||
val info = context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNING_CERTIFICATES)
|
val info =
|
||||||
val signaturesNew = info.signingInfo.signingCertificateHistory ?: info.signingInfo.apkContentsSigners
|
context.packageManager.getPackageInfo(appPackage, PackageManager.GET_SIGNING_CERTIFICATES)
|
||||||
|
val signaturesNew =
|
||||||
|
info.signingInfo.signingCertificateHistory ?: info.signingInfo.apkContentsSigners
|
||||||
val stableHashNew = stableHash(signaturesNew.map { it.toByteArray() })
|
val stableHashNew = stableHash(signaturesNew.map { it.toByteArray() })
|
||||||
if (stableHashNew != stableHashOld)
|
if (stableHashNew != stableHashOld)
|
||||||
tag("CertificatesHash").e { "Mismatch between old and new hash: $stableHashNew != $stableHashOld" }
|
tag("CertificatesHash").e {
|
||||||
|
"Mismatch between old and new hash: $stableHashNew != $stableHashOld"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return stableHashOld
|
return stableHashOld
|
||||||
}
|
}
|
||||||
|
@ -106,7 +111,10 @@ private fun visitViewNodes(structure: AssistStructure, block: (AssistStructure.V
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun visitViewNode(node: AssistStructure.ViewNode, block: (AssistStructure.ViewNode) -> Unit) {
|
private fun visitViewNode(
|
||||||
|
node: AssistStructure.ViewNode,
|
||||||
|
block: (AssistStructure.ViewNode) -> Unit
|
||||||
|
) {
|
||||||
block(node)
|
block(node)
|
||||||
for (i in 0 until node.childCount) {
|
for (i in 0 until node.childCount) {
|
||||||
visitViewNode(node.getChildAt(i), block)
|
visitViewNode(node.getChildAt(i), block)
|
||||||
|
@ -114,7 +122,9 @@ private fun visitViewNode(node: AssistStructure.ViewNode, block: (AssistStructur
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
internal fun AssistStructure.findNodeByAutofillId(autofillId: AutofillId): AssistStructure.ViewNode? {
|
internal fun AssistStructure.findNodeByAutofillId(
|
||||||
|
autofillId: AutofillId
|
||||||
|
): AssistStructure.ViewNode? {
|
||||||
var node: AssistStructure.ViewNode? = null
|
var node: AssistStructure.ViewNode? = null
|
||||||
visitViewNodes(this) { if (it.autofillId == autofillId) node = it }
|
visitViewNodes(this) { if (it.autofillId == autofillId) node = it }
|
||||||
return node
|
return node
|
||||||
|
|
|
@ -56,9 +56,15 @@ public sealed class AutofillScenario<out T : Any> {
|
||||||
username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID)
|
username = clientState.getParcelable(BUNDLE_KEY_USERNAME_ID)
|
||||||
fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME)
|
fillUsername = clientState.getBoolean(BUNDLE_KEY_FILL_USERNAME)
|
||||||
otp = clientState.getParcelable(BUNDLE_KEY_OTP_ID)
|
otp = clientState.getParcelable(BUNDLE_KEY_OTP_ID)
|
||||||
currentPassword.addAll(clientState.getParcelableArrayList(BUNDLE_KEY_CURRENT_PASSWORD_IDS) ?: emptyList())
|
currentPassword.addAll(
|
||||||
newPassword.addAll(clientState.getParcelableArrayList(BUNDLE_KEY_NEW_PASSWORD_IDS) ?: emptyList())
|
clientState.getParcelableArrayList(BUNDLE_KEY_CURRENT_PASSWORD_IDS) ?: emptyList()
|
||||||
genericPassword.addAll(clientState.getParcelableArrayList(BUNDLE_KEY_GENERIC_PASSWORD_IDS) ?: emptyList())
|
)
|
||||||
|
newPassword.addAll(
|
||||||
|
clientState.getParcelableArrayList(BUNDLE_KEY_NEW_PASSWORD_IDS) ?: emptyList()
|
||||||
|
)
|
||||||
|
genericPassword.addAll(
|
||||||
|
clientState.getParcelableArrayList(BUNDLE_KEY_GENERIC_PASSWORD_IDS) ?: emptyList()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
|
@ -227,7 +233,9 @@ public fun Dataset.Builder.fillWith(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal inline fun <T : Any, S : Any> AutofillScenario<T>.map(transform: (T) -> S): AutofillScenario<S> {
|
internal inline fun <T : Any, S : Any> AutofillScenario<T>.map(
|
||||||
|
transform: (T) -> S
|
||||||
|
): AutofillScenario<S> {
|
||||||
val builder = AutofillScenario.Builder<S>()
|
val builder = AutofillScenario.Builder<S>()
|
||||||
builder.username = username?.let(transform)
|
builder.username = username?.let(transform)
|
||||||
builder.fillUsername = fillUsername
|
builder.fillUsername = fillUsername
|
||||||
|
@ -253,7 +261,10 @@ internal fun AutofillScenario<AutofillId>.toBundle(): Bundle =
|
||||||
putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
|
putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
|
||||||
putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
|
putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
|
||||||
putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
|
putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
|
||||||
putParcelableArrayList(AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS, ArrayList(currentPassword))
|
putParcelableArrayList(
|
||||||
|
AutofillScenario.BUNDLE_KEY_CURRENT_PASSWORD_IDS,
|
||||||
|
ArrayList(currentPassword)
|
||||||
|
)
|
||||||
putParcelableArrayList(AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword))
|
putParcelableArrayList(AutofillScenario.BUNDLE_KEY_NEW_PASSWORD_IDS, ArrayList(newPassword))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -262,7 +273,10 @@ internal fun AutofillScenario<AutofillId>.toBundle(): Bundle =
|
||||||
putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
|
putParcelable(AutofillScenario.BUNDLE_KEY_USERNAME_ID, username)
|
||||||
putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
|
putBoolean(AutofillScenario.BUNDLE_KEY_FILL_USERNAME, fillUsername)
|
||||||
putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
|
putParcelable(AutofillScenario.BUNDLE_KEY_OTP_ID, otp)
|
||||||
putParcelableArrayList(AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS, ArrayList(genericPassword))
|
putParcelableArrayList(
|
||||||
|
AutofillScenario.BUNDLE_KEY_GENERIC_PASSWORD_IDS,
|
||||||
|
ArrayList(genericPassword)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,11 +9,14 @@ import androidx.annotation.RequiresApi
|
||||||
import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Certain
|
import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Certain
|
||||||
import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Likely
|
import com.github.androidpasswordstore.autofillparser.CertaintyLevel.Likely
|
||||||
|
|
||||||
private inline fun <T> Pair<T, T>.all(predicate: T.() -> Boolean) = predicate(first) && predicate(second)
|
private inline fun <T> Pair<T, T>.all(predicate: T.() -> Boolean) =
|
||||||
|
predicate(first) && predicate(second)
|
||||||
|
|
||||||
private inline fun <T> Pair<T, T>.any(predicate: T.() -> Boolean) = predicate(first) || predicate(second)
|
private inline fun <T> Pair<T, T>.any(predicate: T.() -> Boolean) =
|
||||||
|
predicate(first) || predicate(second)
|
||||||
|
|
||||||
private inline fun <T> Pair<T, T>.none(predicate: T.() -> Boolean) = !predicate(first) && !predicate(second)
|
private inline fun <T> Pair<T, T>.none(predicate: T.() -> Boolean) =
|
||||||
|
!predicate(first) && !predicate(second)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The strategy used to detect [AutofillScenario] s; expressed using the DSL implemented in
|
* The strategy used to detect [AutofillScenario] s; expressed using the DSL implemented in
|
||||||
|
@ -32,7 +35,8 @@ internal val autofillStrategy = strategy {
|
||||||
}
|
}
|
||||||
currentPassword(optional = true) {
|
currentPassword(optional = true) {
|
||||||
takeSingle { alreadyMatched ->
|
takeSingle { alreadyMatched ->
|
||||||
val adjacentToNewPasswords = directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched)
|
val adjacentToNewPasswords =
|
||||||
|
directlyPrecedes(alreadyMatched) || directlyFollows(alreadyMatched)
|
||||||
// The Autofill framework has not hint that applies to current passwords only.
|
// The Autofill framework has not hint that applies to current passwords only.
|
||||||
// In this scenario, we have already matched fields a pair of fields with a specific
|
// In this scenario, we have already matched fields a pair of fields with a specific
|
||||||
// new password hint, so we take a generic Autofill password hint to mean a current
|
// new password hint, so we take a generic Autofill password hint to mean a current
|
||||||
|
@ -109,7 +113,9 @@ internal val autofillStrategy = strategy {
|
||||||
rule(applyInSingleOriginMode = true) {
|
rule(applyInSingleOriginMode = true) {
|
||||||
newPassword { takeSingle { hasHintNewPassword && isFocused } }
|
newPassword { takeSingle { hasHintNewPassword && isFocused } }
|
||||||
username(optional = true) {
|
username(optional = true) {
|
||||||
takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) }
|
takeSingle { alreadyMatched ->
|
||||||
|
usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,7 +125,9 @@ internal val autofillStrategy = strategy {
|
||||||
rule(applyInSingleOriginMode = true) {
|
rule(applyInSingleOriginMode = true) {
|
||||||
currentPassword { takeSingle { hasAutocompleteHintCurrentPassword && isFocused } }
|
currentPassword { takeSingle { hasAutocompleteHintCurrentPassword && isFocused } }
|
||||||
username(optional = true) {
|
username(optional = true) {
|
||||||
takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) }
|
takeSingle { alreadyMatched ->
|
||||||
|
usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,7 +137,9 @@ internal val autofillStrategy = strategy {
|
||||||
rule(applyInSingleOriginMode = true) {
|
rule(applyInSingleOriginMode = true) {
|
||||||
genericPassword { takeSingle { passwordCertainty >= Likely && isFocused } }
|
genericPassword { takeSingle { passwordCertainty >= Likely && isFocused } }
|
||||||
username(optional = true) {
|
username(optional = true) {
|
||||||
takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) }
|
takeSingle { alreadyMatched ->
|
||||||
|
usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,12 +149,16 @@ internal val autofillStrategy = strategy {
|
||||||
rule {
|
rule {
|
||||||
username { takeSingle { hasHintUsername && isFocused } }
|
username { takeSingle { hasHintUsername && isFocused } }
|
||||||
currentPassword(matchHidden = true) {
|
currentPassword(matchHidden = true) {
|
||||||
takeSingle { alreadyMatched -> directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword }
|
takeSingle { alreadyMatched ->
|
||||||
|
directlyFollows(alreadyMatched.singleOrNull()) && couldBeTwoStepHiddenPassword
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match a single focused OTP field.
|
// Match a single focused OTP field.
|
||||||
rule(applyInSingleOriginMode = true) { otp { takeSingle { otpCertainty >= Likely && isFocused } } }
|
rule(applyInSingleOriginMode = true) {
|
||||||
|
otp { takeSingle { otpCertainty >= Likely && isFocused } }
|
||||||
|
}
|
||||||
|
|
||||||
// Match a single focused username field without a password field.
|
// Match a single focused username field without a password field.
|
||||||
rule(applyInSingleOriginMode = true) {
|
rule(applyInSingleOriginMode = true) {
|
||||||
|
@ -162,7 +176,9 @@ internal val autofillStrategy = strategy {
|
||||||
// This rule can apply in single origin mode since even though the password field may not be
|
// This rule can apply in single origin mode since even though the password field may not be
|
||||||
// focused at the time the rule runs, the fill suggestion will only show if it ever receives
|
// focused at the time the rule runs, the fill suggestion will only show if it ever receives
|
||||||
// focus.
|
// focus.
|
||||||
rule(applyInSingleOriginMode = true) { currentPassword { takeSingle { hasAutocompleteHintCurrentPassword } } }
|
rule(applyInSingleOriginMode = true) {
|
||||||
|
currentPassword { takeSingle { hasAutocompleteHintCurrentPassword } }
|
||||||
|
}
|
||||||
|
|
||||||
// See above.
|
// See above.
|
||||||
rule(applyInSingleOriginMode = true) { genericPassword { takeSingle { true } } }
|
rule(applyInSingleOriginMode = true) { genericPassword { takeSingle { true } } }
|
||||||
|
@ -171,10 +187,14 @@ internal val autofillStrategy = strategy {
|
||||||
rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) {
|
rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) {
|
||||||
genericPassword { takeSingle { isFocused } }
|
genericPassword { takeSingle { isFocused } }
|
||||||
username(optional = true) {
|
username(optional = true) {
|
||||||
takeSingle { alreadyMatched -> usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull()) }
|
takeSingle { alreadyMatched ->
|
||||||
|
usernameCertainty >= Likely && directlyPrecedes(alreadyMatched.singleOrNull())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match any focused username field on manual request.
|
// Match any focused username field on manual request.
|
||||||
rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) { username { takeSingle { isFocused } } }
|
rule(applyInSingleOriginMode = true, applyOnManualRequestOnly = true) {
|
||||||
|
username { takeSingle { isFocused } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,28 +20,41 @@ internal interface FieldMatcher {
|
||||||
class Builder {
|
class Builder {
|
||||||
|
|
||||||
private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
|
private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
|
||||||
private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> = mutableListOf()
|
private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> =
|
||||||
|
mutableListOf()
|
||||||
|
|
||||||
private var takePair: (Pair<FormField, FormField>.(List<FormField>) -> Boolean)? = null
|
private var takePair: (Pair<FormField, FormField>.(List<FormField>) -> Boolean)? = null
|
||||||
private var tieBreakersPair: MutableList<Pair<FormField, FormField>.(List<FormField>) -> Boolean> = mutableListOf()
|
private var tieBreakersPair:
|
||||||
|
MutableList<Pair<FormField, FormField>.(List<FormField>) -> Boolean> =
|
||||||
|
mutableListOf()
|
||||||
|
|
||||||
fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
|
fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
|
||||||
check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
|
check(takeSingle == null && takePair == null) {
|
||||||
|
"Every block can only have at most one take{Single,Pair} block"
|
||||||
|
}
|
||||||
takeSingle = block
|
takeSingle = block
|
||||||
}
|
}
|
||||||
|
|
||||||
fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
|
fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
|
||||||
check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
|
check(takeSingle != null) {
|
||||||
|
"Every block needs a takeSingle block before a breakTieOnSingle block"
|
||||||
|
}
|
||||||
check(takePair == null) { "takePair cannot be mixed with breakTieOnSingle" }
|
check(takePair == null) { "takePair cannot be mixed with breakTieOnSingle" }
|
||||||
tieBreakersSingle.add(block)
|
tieBreakersSingle.add(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun takePair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
|
fun takePair(
|
||||||
check(takeSingle == null && takePair == null) { "Every block can only have at most one take{Single,Pair} block" }
|
block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean = { true }
|
||||||
|
) {
|
||||||
|
check(takeSingle == null && takePair == null) {
|
||||||
|
"Every block can only have at most one take{Single,Pair} block"
|
||||||
|
}
|
||||||
takePair = block
|
takePair = block
|
||||||
}
|
}
|
||||||
|
|
||||||
fun breakTieOnPair(block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean) {
|
fun breakTieOnPair(
|
||||||
|
block: Pair<FormField, FormField>.(alreadyMatched: List<FormField>) -> Boolean
|
||||||
|
) {
|
||||||
check(takePair != null) { "Every block needs a takePair block before a breakTieOnPair block" }
|
check(takePair != null) { "Every block needs a takePair block before a breakTieOnPair block" }
|
||||||
check(takeSingle == null) { "takeSingle cannot be mixed with breakTieOnPair" }
|
check(takeSingle == null) { "takeSingle cannot be mixed with breakTieOnPair" }
|
||||||
tieBreakersPair.add(block)
|
tieBreakersPair.add(block)
|
||||||
|
@ -69,7 +82,8 @@ internal class SingleFieldMatcher(
|
||||||
class Builder {
|
class Builder {
|
||||||
|
|
||||||
private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
|
private var takeSingle: (FormField.(List<FormField>) -> Boolean)? = null
|
||||||
private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> = mutableListOf()
|
private val tieBreakersSingle: MutableList<FormField.(List<FormField>) -> Boolean> =
|
||||||
|
mutableListOf()
|
||||||
|
|
||||||
fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
|
fun takeSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean = { true }) {
|
||||||
check(takeSingle == null) { "Every block can only have at most one takeSingle block" }
|
check(takeSingle == null) { "Every block can only have at most one takeSingle block" }
|
||||||
|
@ -77,7 +91,9 @@ internal class SingleFieldMatcher(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
|
fun breakTieOnSingle(block: FormField.(alreadyMatched: List<FormField>) -> Boolean) {
|
||||||
check(takeSingle != null) { "Every block needs a takeSingle block before a breakTieOnSingle block" }
|
check(takeSingle != null) {
|
||||||
|
"Every block needs a takeSingle block before a breakTieOnSingle block"
|
||||||
|
}
|
||||||
tieBreakersSingle.add(block)
|
tieBreakersSingle.add(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -180,7 +196,10 @@ private constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
@AutofillDsl
|
@AutofillDsl
|
||||||
class Builder(private val applyInSingleOriginMode: Boolean, private val applyOnManualRequestOnly: Boolean) {
|
class Builder(
|
||||||
|
private val applyInSingleOriginMode: Boolean,
|
||||||
|
private val applyOnManualRequestOnly: Boolean
|
||||||
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
|
@ -286,9 +305,13 @@ private constructor(
|
||||||
"Rules with applyInSingleOriginMode set to true must not fill into hidden fields"
|
"Rules with applyInSingleOriginMode set to true must not fill into hidden fields"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return AutofillRule(matchers, applyInSingleOriginMode, applyOnManualRequestOnly, name ?: "Rule #$ruleId").also {
|
return AutofillRule(
|
||||||
ruleId++
|
matchers,
|
||||||
}
|
applyInSingleOriginMode,
|
||||||
|
applyOnManualRequestOnly,
|
||||||
|
name ?: "Rule #$ruleId"
|
||||||
|
)
|
||||||
|
.also { ruleId++ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -409,4 +432,5 @@ internal class AutofillStrategy private constructor(private val rules: List<Auto
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun strategy(block: AutofillStrategy.Builder.() -> Unit) = AutofillStrategy.Builder().apply(block).build()
|
internal fun strategy(block: AutofillStrategy.Builder.() -> Unit) =
|
||||||
|
AutofillStrategy.Builder().apply(block).build()
|
||||||
|
|
|
@ -63,7 +63,10 @@ private val TRUSTED_BROWSER_CERTIFICATE_HASH =
|
||||||
"com.chrome.canary" to arrayOf("IBnfofsj779wxbzRRDxb6rBPPy/0Nm6aweNFdjmiTPw="),
|
"com.chrome.canary" to arrayOf("IBnfofsj779wxbzRRDxb6rBPPy/0Nm6aweNFdjmiTPw="),
|
||||||
"com.chrome.dev" to arrayOf("kETuX+5LvF4h3URmVDHE6x8fcaMnFqC8knvLs5Izyr8="),
|
"com.chrome.dev" to arrayOf("kETuX+5LvF4h3URmVDHE6x8fcaMnFqC8knvLs5Izyr8="),
|
||||||
"com.duckduckgo.mobile.android" to
|
"com.duckduckgo.mobile.android" to
|
||||||
arrayOf("u3uzHFc8RqHaf8XFKKas9DIQhFb+7FCBDH8zaU6z0tQ=", "8HB9AhwL8+b43MEbo/VwBCXVl9yjAaMeIQVWk067Gwo="),
|
arrayOf(
|
||||||
|
"u3uzHFc8RqHaf8XFKKas9DIQhFb+7FCBDH8zaU6z0tQ=",
|
||||||
|
"8HB9AhwL8+b43MEbo/VwBCXVl9yjAaMeIQVWk067Gwo="
|
||||||
|
),
|
||||||
"com.microsoft.emmx" to arrayOf("AeGZlxCoLCdJtNUMRF3IXWcLYTYInQp2anOCfIKh6sk="),
|
"com.microsoft.emmx" to arrayOf("AeGZlxCoLCdJtNUMRF3IXWcLYTYInQp2anOCfIKh6sk="),
|
||||||
"com.opera.mini.native" to arrayOf("V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I="),
|
"com.opera.mini.native" to arrayOf("V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I="),
|
||||||
"com.opera.mini.native.beta" to arrayOf("V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I="),
|
"com.opera.mini.native.beta" to arrayOf("V6y8Ul8bLr0ZGWzW8BQ5fMkQ/RiEHgroUP68Ph5ZP/I="),
|
||||||
|
@ -80,7 +83,8 @@ private val TRUSTED_BROWSER_CERTIFICATE_HASH =
|
||||||
"org.mozilla.klar" to arrayOf("YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w="),
|
"org.mozilla.klar" to arrayOf("YgOkc7421k7jf4f6UA7bx56rkwYQq5ufpMp9XB8bT/w="),
|
||||||
"org.torproject.torbrowser" to arrayOf("IAYfBF5zfGc3XBd5TP7bQ2oDzsa6y3y5+WZCIFyizsg="),
|
"org.torproject.torbrowser" to arrayOf("IAYfBF5zfGc3XBd5TP7bQ2oDzsa6y3y5+WZCIFyizsg="),
|
||||||
"org.ungoogled.chromium.stable" to arrayOf("29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk="),
|
"org.ungoogled.chromium.stable" to arrayOf("29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk="),
|
||||||
"org.ungoogled.chromium.extensions.stable" to arrayOf("29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk="),
|
"org.ungoogled.chromium.extensions.stable" to
|
||||||
|
arrayOf("29UOO5cXoxO/e/hH3hOu6bbtg1My4tK6Eik2Ym5Krtk="),
|
||||||
"com.kiwibrowser.browser" to arrayOf("wGnqlmMy6R4KDDzFd+b1Cf49ndr3AVrQxcXvj9o/hig="),
|
"com.kiwibrowser.browser" to arrayOf("wGnqlmMy6R4KDDzFd+b1Cf49ndr3AVrQxcXvj9o/hig="),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -162,19 +166,30 @@ private val BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY =
|
||||||
|
|
||||||
private fun isNoAccessibilityServiceEnabled(context: Context): Boolean {
|
private fun isNoAccessibilityServiceEnabled(context: Context): Boolean {
|
||||||
// See https://chromium.googlesource.com/chromium/src/+/447a31e977a65e2eb78804e4a09633699b4ede33
|
// See https://chromium.googlesource.com/chromium/src/+/447a31e977a65e2eb78804e4a09633699b4ede33
|
||||||
return Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES)
|
return Settings.Secure.getString(
|
||||||
|
context.contentResolver,
|
||||||
|
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
|
||||||
|
)
|
||||||
.isNullOrEmpty()
|
.isNullOrEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun getBrowserSaveFlag(context: Context, appPackage: String): Int? =
|
private fun getBrowserSaveFlag(context: Context, appPackage: String): Int? =
|
||||||
BROWSER_SAVE_FLAG[appPackage]
|
BROWSER_SAVE_FLAG[appPackage]
|
||||||
?: BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY[appPackage]?.takeIf { isNoAccessibilityServiceEnabled(context) }
|
?: BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY[appPackage]?.takeIf {
|
||||||
|
isNoAccessibilityServiceEnabled(context)
|
||||||
|
}
|
||||||
|
|
||||||
internal data class BrowserAutofillSupportInfo(val multiOriginMethod: BrowserMultiOriginMethod, val saveFlags: Int?)
|
internal data class BrowserAutofillSupportInfo(
|
||||||
|
val multiOriginMethod: BrowserMultiOriginMethod,
|
||||||
|
val saveFlags: Int?
|
||||||
|
)
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
internal fun getBrowserAutofillSupportInfoIfTrusted(context: Context, appPackage: String): BrowserAutofillSupportInfo? {
|
internal fun getBrowserAutofillSupportInfoIfTrusted(
|
||||||
|
context: Context,
|
||||||
|
appPackage: String
|
||||||
|
): BrowserAutofillSupportInfo? {
|
||||||
if (!isTrustedBrowser(context, appPackage)) return null
|
if (!isTrustedBrowser(context, appPackage)) return null
|
||||||
return BrowserAutofillSupportInfo(
|
return BrowserAutofillSupportInfo(
|
||||||
multiOriginMethod = getBrowserMultiOriginMethod(appPackage),
|
multiOriginMethod = getBrowserMultiOriginMethod(appPackage),
|
||||||
|
@ -197,14 +212,18 @@ public enum class BrowserAutofillSupportLevel {
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun getBrowserAutofillSupportLevel(context: Context, appPackage: String): BrowserAutofillSupportLevel {
|
private fun getBrowserAutofillSupportLevel(
|
||||||
|
context: Context,
|
||||||
|
appPackage: String
|
||||||
|
): BrowserAutofillSupportLevel {
|
||||||
val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
|
val browserInfo = getBrowserAutofillSupportInfoIfTrusted(context, appPackage)
|
||||||
return when {
|
return when {
|
||||||
browserInfo == null -> BrowserAutofillSupportLevel.None
|
browserInfo == null -> BrowserAutofillSupportLevel.None
|
||||||
appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill
|
appPackage in FLAKY_BROWSERS -> BrowserAutofillSupportLevel.FlakyFill
|
||||||
appPackage in BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY ->
|
appPackage in BROWSER_SAVE_FLAG_IF_NO_ACCESSIBILITY ->
|
||||||
BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility
|
BrowserAutofillSupportLevel.PasswordFillAndSaveIfNoAccessibility
|
||||||
browserInfo.multiOriginMethod == BrowserMultiOriginMethod.None -> BrowserAutofillSupportLevel.PasswordFill
|
browserInfo.multiOriginMethod == BrowserMultiOriginMethod.None ->
|
||||||
|
BrowserAutofillSupportLevel.PasswordFill
|
||||||
browserInfo.saveFlags == null -> BrowserAutofillSupportLevel.GeneralFill
|
browserInfo.saveFlags == null -> BrowserAutofillSupportLevel.GeneralFill
|
||||||
else -> BrowserAutofillSupportLevel.GeneralFillAndSave
|
else -> BrowserAutofillSupportLevel.GeneralFillAndSave
|
||||||
}.takeUnless { supportLevel ->
|
}.takeUnless { supportLevel ->
|
||||||
|
@ -212,7 +231,8 @@ private fun getBrowserAutofillSupportLevel(context: Context, appPackage: String)
|
||||||
// (compatibility mode is only available on Android Pie and higher). Since all known browsers
|
// (compatibility mode is only available on Android Pie and higher). Since all known browsers
|
||||||
// with native Autofill support offer full save support as well, we reuse the list of those
|
// with native Autofill support offer full save support as well, we reuse the list of those
|
||||||
// browsers here.
|
// browsers here.
|
||||||
supportLevel != BrowserAutofillSupportLevel.GeneralFillAndSave && Build.VERSION.SDK_INT < Build.VERSION_CODES.P
|
supportLevel != BrowserAutofillSupportLevel.GeneralFillAndSave &&
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.P
|
||||||
}
|
}
|
||||||
?: BrowserAutofillSupportLevel.None
|
?: BrowserAutofillSupportLevel.None
|
||||||
}
|
}
|
||||||
|
@ -222,9 +242,15 @@ public fun getInstalledBrowsersWithAutofillSupportLevel(
|
||||||
context: Context
|
context: Context
|
||||||
): List<Pair<String, BrowserAutofillSupportLevel>> {
|
): List<Pair<String, BrowserAutofillSupportLevel>> {
|
||||||
val testWebIntent = Intent(Intent.ACTION_VIEW).apply { data = Uri.parse("http://example.org") }
|
val testWebIntent = Intent(Intent.ACTION_VIEW).apply { data = Uri.parse("http://example.org") }
|
||||||
val installedBrowsers = context.packageManager.queryIntentActivities(testWebIntent, PackageManager.MATCH_ALL)
|
val installedBrowsers =
|
||||||
|
context.packageManager.queryIntentActivities(testWebIntent, PackageManager.MATCH_ALL)
|
||||||
return installedBrowsers
|
return installedBrowsers
|
||||||
.map { it to getBrowserAutofillSupportLevel(context, it.activityInfo.packageName) }
|
.map { it to getBrowserAutofillSupportLevel(context, it.activityInfo.packageName) }
|
||||||
.filter { it.first.isDefault || it.second != BrowserAutofillSupportLevel.None }
|
.filter { it.first.isDefault || it.second != BrowserAutofillSupportLevel.None }
|
||||||
.map { context.packageManager.getApplicationLabel(it.first.activityInfo.applicationInfo).toString() to it.second }
|
.map {
|
||||||
|
context
|
||||||
|
.packageManager
|
||||||
|
.getApplicationLabel(it.first.activityInfo.applicationInfo)
|
||||||
|
.toString() to it.second
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -108,9 +108,14 @@ internal class FormField(
|
||||||
"text",
|
"text",
|
||||||
)
|
)
|
||||||
private val HTML_INPUT_FIELD_TYPES_FILLABLE =
|
private val HTML_INPUT_FIELD_TYPES_FILLABLE =
|
||||||
(HTML_INPUT_FIELD_TYPES_USERNAME + HTML_INPUT_FIELD_TYPES_PASSWORD + HTML_INPUT_FIELD_TYPES_OTP).toSet().toList()
|
(HTML_INPUT_FIELD_TYPES_USERNAME +
|
||||||
|
HTML_INPUT_FIELD_TYPES_PASSWORD +
|
||||||
|
HTML_INPUT_FIELD_TYPES_OTP)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O) private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
private fun isSupportedHint(hint: String) = hint in HINTS_FILLABLE
|
||||||
private val EXCLUDED_TERMS =
|
private val EXCLUDED_TERMS =
|
||||||
listOf(
|
listOf(
|
||||||
"url_bar", // Chrome/Edge/Firefox address bar
|
"url_bar", // Chrome/Edge/Firefox address bar
|
||||||
|
@ -214,7 +219,8 @@ internal class FormField(
|
||||||
private val hasAutocompleteHintUsername = htmlAutocomplete == "username"
|
private val hasAutocompleteHintUsername = htmlAutocomplete == "username"
|
||||||
val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password"
|
val hasAutocompleteHintCurrentPassword = htmlAutocomplete == "current-password"
|
||||||
private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password"
|
private val hasAutocompleteHintNewPassword = htmlAutocomplete == "new-password"
|
||||||
private val hasAutocompleteHintPassword = hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword
|
private val hasAutocompleteHintPassword =
|
||||||
|
hasAutocompleteHintCurrentPassword || hasAutocompleteHintNewPassword
|
||||||
private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code"
|
private val hasAutocompleteHintOtp = htmlAutocomplete == "one-time-code"
|
||||||
|
|
||||||
// Results of hint-based field type detection
|
// Results of hint-based field type detection
|
||||||
|
@ -238,7 +244,9 @@ internal class FormField(
|
||||||
// fields to the fill rules and only exclude those fields that have incompatible autocomplete
|
// fields to the fill rules and only exclude those fields that have incompatible autocomplete
|
||||||
// hint.
|
// hint.
|
||||||
val couldBeTwoStepHiddenPassword =
|
val couldBeTwoStepHiddenPassword =
|
||||||
!isVisible && isHtmlPasswordField && (hasAutocompleteHintCurrentPassword || htmlAutocomplete == null)
|
!isVisible &&
|
||||||
|
isHtmlPasswordField &&
|
||||||
|
(hasAutocompleteHintCurrentPassword || htmlAutocomplete == null)
|
||||||
|
|
||||||
// Since many site put autocomplete=off on login forms for compliance reasons or since they are
|
// Since many site put autocomplete=off on login forms for compliance reasons or since they are
|
||||||
// worried of the user's browser automatically (i.e., without any user interaction) filling
|
// worried of the user's browser automatically (i.e., without any user interaction) filling
|
||||||
|
@ -247,7 +255,8 @@ internal class FormField(
|
||||||
private val excludedByHints = excludedByAutofillHints
|
private val excludedByHints = excludedByAutofillHints
|
||||||
|
|
||||||
// Only offer to fill into custom views if they explicitly opted into Autofill.
|
// Only offer to fill into custom views if they explicitly opted into Autofill.
|
||||||
val relevantField = hasAutofillTypeText && (isTextField || autofillHints.isNotEmpty()) && !excludedByHints
|
val relevantField =
|
||||||
|
hasAutofillTypeText && (isTextField || autofillHints.isNotEmpty()) && !excludedByHints
|
||||||
|
|
||||||
// Exclude fields based on hint, resource ID or HTML name.
|
// Exclude fields based on hint, resource ID or HTML name.
|
||||||
// Note: We still report excluded fields as relevant since they count for adjacency heuristics,
|
// Note: We still report excluded fields as relevant since they count for adjacency heuristics,
|
||||||
|
@ -260,7 +269,8 @@ internal class FormField(
|
||||||
notExcluded && (isAndroidPasswordField || isHtmlPasswordField || hasHintPassword)
|
notExcluded && (isAndroidPasswordField || isHtmlPasswordField || hasHintPassword)
|
||||||
private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword
|
private val isCertainPasswordField = isPossiblePasswordField && hasHintPassword
|
||||||
private val isLikelyPasswordField =
|
private val isLikelyPasswordField =
|
||||||
isPossiblePasswordField && (isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo)
|
isPossiblePasswordField &&
|
||||||
|
(isCertainPasswordField || PASSWORD_HEURISTIC_TERMS.anyMatchesFieldInfo)
|
||||||
val passwordCertainty =
|
val passwordCertainty =
|
||||||
if (isCertainPasswordField) CertaintyLevel.Certain
|
if (isCertainPasswordField) CertaintyLevel.Certain
|
||||||
else if (isLikelyPasswordField) CertaintyLevel.Likely
|
else if (isLikelyPasswordField) CertaintyLevel.Likely
|
||||||
|
@ -273,17 +283,20 @@ internal class FormField(
|
||||||
isPossibleOtpField &&
|
isPossibleOtpField &&
|
||||||
(isCertainOtpField ||
|
(isCertainOtpField ||
|
||||||
OTP_HEURISTIC_TERMS.anyMatchesFieldInfo ||
|
OTP_HEURISTIC_TERMS.anyMatchesFieldInfo ||
|
||||||
((htmlMaxLength == null || htmlMaxLength in 6..8) && OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo))
|
((htmlMaxLength == null || htmlMaxLength in 6..8) &&
|
||||||
|
OTP_WEAK_HEURISTIC_TERMS.anyMatchesFieldInfo))
|
||||||
val otpCertainty =
|
val otpCertainty =
|
||||||
if (isCertainOtpField) CertaintyLevel.Certain
|
if (isCertainOtpField) CertaintyLevel.Certain
|
||||||
else if (isLikelyOtpField) CertaintyLevel.Likely
|
else if (isLikelyOtpField) CertaintyLevel.Likely
|
||||||
else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible
|
else if (isPossibleOtpField) CertaintyLevel.Possible else CertaintyLevel.Impossible
|
||||||
|
|
||||||
// Username field heuristics (based only on the current field)
|
// Username field heuristics (based only on the current field)
|
||||||
private val isPossibleUsernameField = notExcluded && !isPossiblePasswordField && !isCertainOtpField
|
private val isPossibleUsernameField =
|
||||||
|
notExcluded && !isPossiblePasswordField && !isCertainOtpField
|
||||||
private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername
|
private val isCertainUsernameField = isPossibleUsernameField && hasHintUsername
|
||||||
private val isLikelyUsernameField =
|
private val isLikelyUsernameField =
|
||||||
isPossibleUsernameField && (isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo))
|
isPossibleUsernameField &&
|
||||||
|
(isCertainUsernameField || (USERNAME_HEURISTIC_TERMS.anyMatchesFieldInfo))
|
||||||
val usernameCertainty =
|
val usernameCertainty =
|
||||||
if (isCertainUsernameField) CertaintyLevel.Certain
|
if (isCertainUsernameField) CertaintyLevel.Certain
|
||||||
else if (isLikelyUsernameField) CertaintyLevel.Likely
|
else if (isLikelyUsernameField) CertaintyLevel.Likely
|
||||||
|
|
|
@ -34,12 +34,18 @@ public fun cachePublicSuffixList(context: Context) {
|
||||||
* Note: Invalid domains, such as IP addresses, are returned unchanged and thus never collide with
|
* Note: Invalid domains, such as IP addresses, are returned unchanged and thus never collide with
|
||||||
* the return value for valid domains.
|
* the return value for valid domains.
|
||||||
*/
|
*/
|
||||||
internal fun getPublicSuffixPlusOne(context: Context, domain: String, customSuffixes: Sequence<String>) = runBlocking {
|
internal fun getPublicSuffixPlusOne(
|
||||||
|
context: Context,
|
||||||
|
domain: String,
|
||||||
|
customSuffixes: Sequence<String>
|
||||||
|
) = runBlocking {
|
||||||
// We only feed valid domain names which are not IP addresses into getPublicSuffixPlusOne.
|
// We only feed valid domain names which are not IP addresses into getPublicSuffixPlusOne.
|
||||||
// We do not check whether the domain actually exists (actually, not even whether its TLD
|
// We do not check whether the domain actually exists (actually, not even whether its TLD
|
||||||
// exists). As long as we restrict ourselves to syntactically valid domain names,
|
// exists). As long as we restrict ourselves to syntactically valid domain names,
|
||||||
// getPublicSuffixPlusOne will return non-colliding results.
|
// getPublicSuffixPlusOne will return non-colliding results.
|
||||||
if (!Patterns.DOMAIN_NAME.matcher(domain).matches() || Patterns.IP_ADDRESS.matcher(domain).matches()) {
|
if (!Patterns.DOMAIN_NAME.matcher(domain).matches() ||
|
||||||
|
Patterns.IP_ADDRESS.matcher(domain).matches()
|
||||||
|
) {
|
||||||
domain
|
domain
|
||||||
} else {
|
} else {
|
||||||
getCanonicalSuffix(context, domain, customSuffixes)
|
getCanonicalSuffix(context, domain, customSuffixes)
|
||||||
|
@ -60,7 +66,11 @@ private fun getSuffixPlusUpToOne(domain: String, suffix: String): String? {
|
||||||
return "$lastPrefixPart.$suffix"
|
return "$lastPrefixPart.$suffix"
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getCanonicalSuffix(context: Context, domain: String, customSuffixes: Sequence<String>): String {
|
private suspend fun getCanonicalSuffix(
|
||||||
|
context: Context,
|
||||||
|
domain: String,
|
||||||
|
customSuffixes: Sequence<String>
|
||||||
|
): String {
|
||||||
val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context)
|
val publicSuffixList = PublicSuffixListCache.getOrCachePublicSuffixList(context)
|
||||||
val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await() ?: return domain
|
val publicSuffixPlusOne = publicSuffixList.getPublicSuffixPlusOne(domain).await() ?: return domain
|
||||||
var longestSuffix = publicSuffixPlusOne
|
var longestSuffix = publicSuffixPlusOne
|
||||||
|
|
|
@ -58,7 +58,8 @@ internal class PublicSuffixList(
|
||||||
fun getPublicSuffixPlusOne(domain: String): Deferred<String?> =
|
fun getPublicSuffixPlusOne(domain: String): Deferred<String?> =
|
||||||
scope.async {
|
scope.async {
|
||||||
when (val offset = data.getPublicSuffixOffset(domain)) {
|
when (val offset = data.getPublicSuffixOffset(domain)) {
|
||||||
is PublicSuffixOffset.Offset -> domain.split('.').drop(offset.value).joinToString(separator = ".")
|
is PublicSuffixOffset.Offset ->
|
||||||
|
domain.split('.').drop(offset.value).joinToString(separator = ".")
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,7 +12,10 @@ import java.net.IDN
|
||||||
import mozilla.components.lib.publicsuffixlist.ext.binarySearch
|
import mozilla.components.lib.publicsuffixlist.ext.binarySearch
|
||||||
|
|
||||||
/** Class wrapping the public suffix list data and offering methods for accessing rules in it. */
|
/** Class wrapping the public suffix list data and offering methods for accessing rules in it. */
|
||||||
internal class PublicSuffixListData(private val rules: ByteArray, private val exceptions: ByteArray) {
|
internal class PublicSuffixListData(
|
||||||
|
private val rules: ByteArray,
|
||||||
|
private val exceptions: ByteArray
|
||||||
|
) {
|
||||||
|
|
||||||
private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? {
|
private fun binarySearchRules(labels: List<ByteArray>, labelIndex: Int): String? {
|
||||||
return rules.binarySearch(labels, labelIndex)
|
return rules.binarySearch(labels, labelIndex)
|
||||||
|
|
|
@ -30,7 +30,12 @@ internal object PublicSuffixListLoader {
|
||||||
|
|
||||||
@Suppress("MagicNumber")
|
@Suppress("MagicNumber")
|
||||||
private fun BufferedInputStream.readInt(): Int {
|
private fun BufferedInputStream.readInt(): Int {
|
||||||
return (read() and 0xff shl 24 or (read() and 0xff shl 16) or (read() and 0xff shl 8) or (read() and 0xff))
|
return (read() and
|
||||||
|
0xff shl
|
||||||
|
24 or
|
||||||
|
(read() and 0xff shl 16) or
|
||||||
|
(read() and 0xff shl 8) or
|
||||||
|
(read() and 0xff))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun BufferedInputStream.readFully(size: Int): ByteArray {
|
private fun BufferedInputStream.readFully(size: Int): ByteArray {
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
/**
|
|
||||||
* IDEs don't support this very well for buildSrc, so we use the regular dependency format
|
|
||||||
* until that changes.
|
|
||||||
dependencyResolutionManagement {
|
|
||||||
versionCatalogs {
|
|
||||||
create("libs") {
|
|
||||||
from(files("../gradle/libs.versions.toml"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
|
@ -115,11 +115,13 @@ constructor(
|
||||||
.lineSequence()
|
.lineSequence()
|
||||||
.filter { line ->
|
.filter { line ->
|
||||||
return@filter when {
|
return@filter when {
|
||||||
USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } && !foundUsername -> {
|
USERNAME_FIELDS.any { prefix -> line.startsWith(prefix, ignoreCase = true) } &&
|
||||||
|
!foundUsername -> {
|
||||||
foundUsername = true
|
foundUsername = true
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
line.startsWith("otpauth://", ignoreCase = true) || line.startsWith("totp:", ignoreCase = true) -> {
|
line.startsWith("otpauth://", ignoreCase = true) ||
|
||||||
|
line.startsWith("totp:", ignoreCase = true) -> {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
@ -174,7 +176,8 @@ constructor(
|
||||||
private fun findUsername(): String? {
|
private fun findUsername(): String? {
|
||||||
extraContentString.splitToSequence("\n").forEach { line ->
|
extraContentString.splitToSequence("\n").forEach { line ->
|
||||||
for (prefix in USERNAME_FIELDS) {
|
for (prefix in USERNAME_FIELDS) {
|
||||||
if (line.startsWith(prefix, ignoreCase = true)) return line.substring(prefix.length).trimStart()
|
if (line.startsWith(prefix, ignoreCase = true))
|
||||||
|
return line.substring(prefix.length).trimStart()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
|
|
|
@ -23,7 +23,8 @@ internal object Otp {
|
||||||
check(STEAM_ALPHABET.size == 26)
|
check(STEAM_ALPHABET.size == 26)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun calculateCode(secret: String, counter: Long, algorithm: String, digits: String) = runCatching {
|
fun calculateCode(secret: String, counter: Long, algorithm: String, digits: String) =
|
||||||
|
runCatching {
|
||||||
val algo = "Hmac${algorithm.uppercase(Locale.ROOT)}"
|
val algo = "Hmac${algorithm.uppercase(Locale.ROOT)}"
|
||||||
val decodedSecret = BASE_32.decode(secret)
|
val decodedSecret = BASE_32.decode(secret)
|
||||||
val secretKey = SecretKeySpec(decodedSecret, algo)
|
val secretKey = SecretKeySpec(decodedSecret, algo)
|
||||||
|
|
|
@ -19,7 +19,8 @@ import org.junit.Test
|
||||||
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
|
@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class)
|
||||||
internal class PasswordEntryTest {
|
internal class PasswordEntryTest {
|
||||||
|
|
||||||
private fun makeEntry(content: String) = PasswordEntry(fakeClock, testFinder, testScope, content.encodeToByteArray())
|
private fun makeEntry(content: String) =
|
||||||
|
PasswordEntry(fakeClock, testFinder, testScope, content.encodeToByteArray())
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testGetPassword() {
|
fun testGetPassword() {
|
||||||
|
@ -49,7 +50,10 @@ internal class PasswordEntryTest {
|
||||||
assertEquals("blubb", makeEntry("\nblubb").extraContentString)
|
assertEquals("blubb", makeEntry("\nblubb").extraContentString)
|
||||||
assertEquals("blubb", makeEntry("blubb\npassword: foo").extraContentString)
|
assertEquals("blubb", makeEntry("blubb\npassword: foo").extraContentString)
|
||||||
assertEquals("blubb", makeEntry("password: foo\nblubb").extraContentString)
|
assertEquals("blubb", makeEntry("password: foo\nblubb").extraContentString)
|
||||||
assertEquals("blubb\nusername: bar", makeEntry("blubb\npassword: foo\nusername: bar").extraContentString)
|
assertEquals(
|
||||||
|
"blubb\nusername: bar",
|
||||||
|
makeEntry("blubb\npassword: foo\nusername: bar").extraContentString
|
||||||
|
)
|
||||||
assertEquals("", makeEntry("\n").extraContentString)
|
assertEquals("", makeEntry("\n").extraContentString)
|
||||||
assertEquals("", makeEntry("").extraContentString)
|
assertEquals("", makeEntry("").extraContentString)
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,16 +15,34 @@ internal class OtpTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testOtpGeneration6Digits() {
|
fun testOtpGeneration6Digits() {
|
||||||
assertEquals("953550", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333298159 / (1000 * 30), "SHA1", "6").get())
|
assertEquals(
|
||||||
assertEquals("275379", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333571918 / (1000 * 30), "SHA1", "6").get())
|
"953550",
|
||||||
assertEquals("867507", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333600517 / (1000 * 57), "SHA1", "6").get())
|
Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333298159 / (1000 * 30), "SHA1", "6").get()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"275379",
|
||||||
|
Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333571918 / (1000 * 30), "SHA1", "6").get()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"867507",
|
||||||
|
Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333600517 / (1000 * 57), "SHA1", "6").get()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testOtpGeneration10Digits() {
|
fun testOtpGeneration10Digits() {
|
||||||
assertEquals("0740900914", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333655044 / (1000 * 30), "SHA1", "10").get())
|
assertEquals(
|
||||||
assertEquals("0070632029", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333691405 / (1000 * 30), "SHA1", "10").get())
|
"0740900914",
|
||||||
assertEquals("1017265882", Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333728893 / (1000 * 83), "SHA1", "10").get())
|
Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333655044 / (1000 * 30), "SHA1", "10").get()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"0070632029",
|
||||||
|
Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333691405 / (1000 * 30), "SHA1", "10").get()
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"1017265882",
|
||||||
|
Otp.calculateCode("JBSWY3DPEHPK3PXP", 1593333728893 / (1000 * 83), "SHA1", "10").get()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -42,7 +60,10 @@ internal class OtpTest {
|
||||||
"127764",
|
"127764",
|
||||||
Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAAAAA", 1593367111963 / (1000 * 30), "SHA1", "6").get()
|
Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAAAAA", 1593367111963 / (1000 * 30), "SHA1", "6").get()
|
||||||
)
|
)
|
||||||
assertEquals("047515", Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAA", 1593367171420 / (1000 * 30), "SHA1", "6").get())
|
assertEquals(
|
||||||
|
"047515",
|
||||||
|
Otp.calculateCode("JBSWY3DPEHPK3PXPAAAAA", 1593367171420 / (1000 * 30), "SHA1", "6").get()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue