aboutsummaryrefslogtreecommitdiffstats
path: root/app/src/main/java/dev/msfjarvis/aps/util/autofill
diff options
context:
space:
mode:
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/util/autofill')
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt301
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt286
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt205
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt324
-rw-r--r--app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt108
5 files changed, 605 insertions, 619 deletions
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
index fa2accdb..a39db31d 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/Api30AutofillResponseBuilder.kt
@@ -27,163 +27,166 @@ import dev.msfjarvis.aps.ui.autofill.AutofillPublisherChangedActivity
import dev.msfjarvis.aps.ui.autofill.AutofillSaveActivity
import java.io.File
-/**
- * Implements [AutofillResponseBuilder]'s methods for API 30 and above
- */
+/** Implements [AutofillResponseBuilder]'s methods for API 30 and above */
@RequiresApi(Build.VERSION_CODES.R)
class Api30AutofillResponseBuilder(form: FillableForm) {
- private val formOrigin = form.formOrigin
- private val scenario = form.scenario
- private val ignoredIds = form.ignoredIds
- private val saveFlags = form.saveFlags
- private val clientState = form.toClientState()
-
- // We do not offer save when the only relevant field is a username field or there is no field.
- private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
- private val canBeSaved = saveFlags != null && scenarioSupportsSave
-
- private fun makeIntentDataset(
- context: Context,
- action: AutofillAction,
- intentSender: IntentSender,
- metadata: DatasetMetadata,
- imeSpec: InlinePresentationSpec?,
- ): Dataset {
- return Dataset.Builder(makeRemoteView(context, metadata)).run {
- fillWith(scenario, action, credentials = null)
- setAuthentication(intentSender)
- if (imeSpec != null) {
- val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata)
- if (inlinePresentation != null) {
- setInlinePresentation(inlinePresentation)
- }
- }
- build()
+ private val formOrigin = form.formOrigin
+ private val scenario = form.scenario
+ private val ignoredIds = form.ignoredIds
+ private val saveFlags = form.saveFlags
+ private val clientState = form.toClientState()
+
+ // We do not offer save when the only relevant field is a username field or there is no field.
+ private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
+ private val canBeSaved = saveFlags != null && scenarioSupportsSave
+
+ private fun makeIntentDataset(
+ context: Context,
+ action: AutofillAction,
+ intentSender: IntentSender,
+ metadata: DatasetMetadata,
+ imeSpec: InlinePresentationSpec?,
+ ): Dataset {
+ return Dataset.Builder(makeRemoteView(context, metadata)).run {
+ fillWith(scenario, action, credentials = null)
+ setAuthentication(intentSender)
+ if (imeSpec != null) {
+ val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata)
+ if (inlinePresentation != null) {
+ setInlinePresentation(inlinePresentation)
}
+ }
+ build()
}
-
- private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
- val metadata = makeFillMatchMetadata(context, file)
- val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
- return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
- }
-
- private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
- val metadata = makeSearchAndFillMetadata(context)
- val intentSender =
- AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
- return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec)
- }
-
- private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
- val metadata = makeGenerateAndFillMetadata(context)
- val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
- return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec)
- }
-
-
- private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
- if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
- val metadata = makeFillOtpFromSmsMetadata(context)
- val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
- return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec)
- }
-
- private fun makePublisherChangedDataset(
- context: Context,
- publisherChangedException: AutofillPublisherChangedException,
- imeSpec: InlinePresentationSpec?
- ): Dataset {
- val metadata = makeWarningMetadata(context)
- // If the user decides to trust the new publisher, they can choose reset the list of
- // matches. In this case we need to immediately show a new `FillResponse` as if the app were
- // autofilled for the first time. This `FillResponse` needs to be returned as a result from
- // `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
- val fillResponseAfterReset = makeFillResponse(context, null, emptyList())
- val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
- context, publisherChangedException, fillResponseAfterReset
- )
- return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
+ }
+
+ private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
+ val metadata = makeFillMatchMetadata(context, file)
+ val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
+ return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
+ }
+
+ private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
+ val metadata = makeSearchAndFillMetadata(context)
+ val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
+ return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec)
+ }
+
+ private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
+ val metadata = makeGenerateAndFillMetadata(context)
+ val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
+ return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec)
+ }
+
+ private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
+ if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
+ val metadata = makeFillOtpFromSmsMetadata(context)
+ val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
+ return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec)
+ }
+
+ private fun makePublisherChangedDataset(
+ context: Context,
+ publisherChangedException: AutofillPublisherChangedException,
+ imeSpec: InlinePresentationSpec?
+ ): Dataset {
+ val metadata = makeWarningMetadata(context)
+ // If the user decides to trust the new publisher, they can choose reset the list of
+ // matches. In this case we need to immediately show a new `FillResponse` as if the app were
+ // autofilled for the first time. This `FillResponse` needs to be returned as a result from
+ // `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
+ val fillResponseAfterReset = makeFillResponse(context, null, emptyList())
+ val intentSender =
+ AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
+ context,
+ publisherChangedException,
+ fillResponseAfterReset
+ )
+ return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec)
+ }
+
+ private fun makePublisherChangedResponse(
+ context: Context,
+ inlineSuggestionsRequest: InlineSuggestionsRequest?,
+ publisherChangedException: AutofillPublisherChangedException
+ ): FillResponse {
+ val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull()
+ return FillResponse.Builder().run {
+ addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec))
+ setIgnoredIds(*ignoredIds.toTypedArray())
+ build()
}
-
- private fun makePublisherChangedResponse(
- context: Context,
- inlineSuggestionsRequest: InlineSuggestionsRequest?,
- publisherChangedException: AutofillPublisherChangedException
- ): FillResponse {
- val imeSpec = inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull()
- return FillResponse.Builder().run {
- addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec))
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
+ }
+
+ private fun makeFillResponse(
+ context: Context,
+ inlineSuggestionsRequest: InlineSuggestionsRequest?,
+ matchedFiles: List<File>
+ ): FillResponse? {
+ var datasetCount = 0
+ val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
+ return FillResponse.Builder().run {
+ for (file in matchedFiles) {
+ makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
}
+ }
+ makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ if (datasetCount == 0) return null
+ setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
+ makeSaveInfo()?.let { setSaveInfo(it) }
+ setClientState(clientState)
+ setIgnoredIds(*ignoredIds.toTypedArray())
+ build()
}
-
- private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List<File>): FillResponse? {
- var datasetCount = 0
- val imeSpecs = inlineSuggestionsRequest?.inlinePresentationSpecs ?: emptyList()
- return FillResponse.Builder().run {
- for (file in matchedFiles) {
- makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- }
- makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let {
- datasetCount++
- addDataset(it)
- }
- if (datasetCount == 0) return null
- setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
- makeSaveInfo()?.let { setSaveInfo(it) }
- setClientState(clientState)
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
+ }
+
+ // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
+ // See:
+ // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
+ private fun makeSaveInfo(): SaveInfo? {
+ if (!canBeSaved) return null
+ check(saveFlags != null)
+ val idsToSave = scenario.fieldsToSave.toTypedArray()
+ if (idsToSave.isEmpty()) return null
+ var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
+ if (scenario.hasUsername) {
+ saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
}
-
- // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
- // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
- private fun makeSaveInfo(): SaveInfo? {
- if (!canBeSaved) return null
- check(saveFlags != null)
- val idsToSave = scenario.fieldsToSave.toTypedArray()
- if (idsToSave.isEmpty()) return null
- var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
- if (scenario.hasUsername) {
- saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
- }
- return SaveInfo.Builder(saveDataTypes, idsToSave).run {
- setFlags(saveFlags)
- build()
- }
- }
-
- /**
- * Creates and returns a suitable [FillResponse] to the Autofill framework.
- */
- fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) {
- AutofillMatcher.getMatchesFor(context, formOrigin).fold(
- success = { matchedFiles ->
- callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
- },
- failure = { e ->
- e(e)
- callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e))
- }
- )
+ return SaveInfo.Builder(saveDataTypes, idsToSave).run {
+ setFlags(saveFlags)
+ build()
}
+ }
+
+ /** Creates and returns a suitable [FillResponse] to the Autofill framework. */
+ fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) {
+ AutofillMatcher.getMatchesFor(context, formOrigin)
+ .fold(
+ success = { matchedFiles ->
+ callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles))
+ },
+ failure = { e ->
+ e(e)
+ callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e))
+ }
+ )
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt
index 955aa047..418843f6 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillMatcher.kt
@@ -21,173 +21,165 @@ import java.io.File
private const val PREFERENCES_AUTOFILL_APP_MATCHES = "oreo_autofill_app_matches"
private val Context.autofillAppMatches
- get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
+ get() = getSharedPreferences(PREFERENCES_AUTOFILL_APP_MATCHES, Context.MODE_PRIVATE)
private const val PREFERENCES_AUTOFILL_WEB_MATCHES = "oreo_autofill_web_matches"
private val Context.autofillWebMatches
- get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
+ get() = getSharedPreferences(PREFERENCES_AUTOFILL_WEB_MATCHES, Context.MODE_PRIVATE)
private fun Context.matchPreferences(formOrigin: FormOrigin): SharedPreferences {
- return when (formOrigin) {
- is FormOrigin.App -> autofillAppMatches
- is FormOrigin.Web -> autofillWebMatches
- }
+ return when (formOrigin) {
+ is FormOrigin.App -> autofillAppMatches
+ is FormOrigin.Web -> autofillWebMatches
+ }
}
class AutofillPublisherChangedException(val formOrigin: FormOrigin) :
- Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
+ Exception("The publisher of '${formOrigin.identifier}' changed since an entry was first matched with this app") {
- init {
- require(formOrigin is FormOrigin.App)
- }
+ init {
+ require(formOrigin is FormOrigin.App)
+ }
}
-/**
- * Manages "matches", i.e., associations between apps or websites and Password Store entries.
- */
+/** Manages "matches", i.e., associations between apps or websites and Password Store entries. */
class AutofillMatcher {
- companion object {
-
- private const val MAX_NUM_MATCHES = 10
-
- private const val PREFERENCE_PREFIX_TOKEN = "token;"
- private fun tokenKey(formOrigin: FormOrigin.App) =
- "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
-
- private const val PREFERENCE_PREFIX_MATCHES = "matches;"
- private fun matchesKey(formOrigin: FormOrigin) =
- "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
-
- private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
- return when (formOrigin) {
- is FormOrigin.Web -> false
- is FormOrigin.App -> {
- val packageName = formOrigin.identifier
- val certificatesHash = computeCertificatesHash(context, packageName)
- val storedCertificatesHash =
- context.autofillAppMatches.getString(tokenKey(formOrigin), null)
- ?: return false
- val hashHasChanged = certificatesHash != storedCertificatesHash
- if (hashHasChanged) {
- e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
- true
- } else {
- false
- }
- }
- }
+ companion object {
+
+ private const val MAX_NUM_MATCHES = 10
+
+ private const val PREFERENCE_PREFIX_TOKEN = "token;"
+ private fun tokenKey(formOrigin: FormOrigin.App) = "$PREFERENCE_PREFIX_TOKEN${formOrigin.identifier}"
+
+ private const val PREFERENCE_PREFIX_MATCHES = "matches;"
+ private fun matchesKey(formOrigin: FormOrigin) = "$PREFERENCE_PREFIX_MATCHES${formOrigin.identifier}"
+
+ private fun hasFormOriginHashChanged(context: Context, formOrigin: FormOrigin): Boolean {
+ return when (formOrigin) {
+ is FormOrigin.Web -> false
+ is FormOrigin.App -> {
+ val packageName = formOrigin.identifier
+ val certificatesHash = computeCertificatesHash(context, packageName)
+ val storedCertificatesHash = context.autofillAppMatches.getString(tokenKey(formOrigin), null) ?: return false
+ val hashHasChanged = certificatesHash != storedCertificatesHash
+ if (hashHasChanged) {
+ e { "$packageName: stored=$storedCertificatesHash, new=$certificatesHash" }
+ true
+ } else {
+ false
+ }
}
+ }
+ }
- private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
- if (formOrigin is FormOrigin.App) {
- val packageName = formOrigin.identifier
- val certificatesHash = computeCertificatesHash(context, packageName)
- context.autofillAppMatches.edit {
- putString(tokenKey(formOrigin), certificatesHash)
- }
- }
- // We don't need to store a hash for FormOrigin.Web since it can only originate from
- // browsers we trust to verify the origin.
- }
+ private fun storeFormOriginHash(context: Context, formOrigin: FormOrigin) {
+ if (formOrigin is FormOrigin.App) {
+ val packageName = formOrigin.identifier
+ val certificatesHash = computeCertificatesHash(context, packageName)
+ context.autofillAppMatches.edit { putString(tokenKey(formOrigin), certificatesHash) }
+ }
+ // We don't need to store a hash for FormOrigin.Web since it can only originate from
+ // browsers we trust to verify the origin.
+ }
- /**
- * Get all Password Store entries that have already been associated with [formOrigin] by the
- * user.
- *
- * If [formOrigin] represents an app and that app's certificates have changed since the
- * first time the user associated an entry with it, an [AutofillPublisherChangedException]
- * will be thrown.
- */
- fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> {
- if (hasFormOriginHashChanged(context, formOrigin)) {
- return Err(AutofillPublisherChangedException(formOrigin))
- }
- val matchPreferences = context.matchPreferences(formOrigin)
- val matchedFiles =
- matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
- return Ok(matchedFiles.filter { it.exists() }.also { validFiles ->
- matchPreferences.edit {
- putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet())
- }
- })
+ /**
+ * Get all Password Store entries that have already been associated with [formOrigin] by the
+ * user.
+ *
+ * If [formOrigin] represents an app and that app's certificates have changed since the first
+ * time the user associated an entry with it, an [AutofillPublisherChangedException] will be
+ * thrown.
+ */
+ fun getMatchesFor(context: Context, formOrigin: FormOrigin): Result<List<File>, AutofillPublisherChangedException> {
+ if (hasFormOriginHashChanged(context, formOrigin)) {
+ return Err(AutofillPublisherChangedException(formOrigin))
+ }
+ val matchPreferences = context.matchPreferences(formOrigin)
+ val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
+ return Ok(
+ matchedFiles.filter { it.exists() }.also { validFiles ->
+ matchPreferences.edit { putStringSet(matchesKey(formOrigin), validFiles.map { it.absolutePath }.toSet()) }
}
+ )
+ }
- fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
- context.matchPreferences(formOrigin).edit {
- remove(matchesKey(formOrigin))
- if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
- }
- }
+ fun clearMatchesFor(context: Context, formOrigin: FormOrigin) {
+ context.matchPreferences(formOrigin).edit {
+ remove(matchesKey(formOrigin))
+ if (formOrigin is FormOrigin.App) remove(tokenKey(formOrigin))
+ }
+ }
- /**
- * Associates the store entry [file] with [formOrigin], such that future Autofill responses
- * to requests from this app or website offer this entry as a dataset.
- *
- * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of
- * Android may crash when too many datasets are offered.
- */
- fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
- if (!file.exists()) return
- if (hasFormOriginHashChanged(context, formOrigin)) {
- // This should never happen since we already verified the publisher in
- // getMatchesFor.
- e { "App publisher changed between getMatchesFor and addMatchFor" }
- throw AutofillPublisherChangedException(formOrigin)
- }
- val matchPreferences = context.matchPreferences(formOrigin)
- val matchedFiles =
- matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
- val newFiles = setOf(file.absoluteFile).union(matchedFiles)
- if (newFiles.size > MAX_NUM_MATCHES) {
- Toast.makeText(
- context,
- context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
- Toast.LENGTH_LONG
- ).show()
- return
- }
- matchPreferences.edit {
- putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet())
- }
- storeFormOriginHash(context, formOrigin)
- d { "Stored match for $formOrigin" }
- }
+ /**
+ * Associates the store entry [file] with [formOrigin], such that future Autofill responses to
+ * requests from this app or website offer this entry as a dataset.
+ *
+ * The maximum number of matches is limited by [MAX_NUM_MATCHES] since older versions of Android
+ * may crash when too many datasets are offered.
+ */
+ fun addMatchFor(context: Context, formOrigin: FormOrigin, file: File) {
+ if (!file.exists()) return
+ if (hasFormOriginHashChanged(context, formOrigin)) {
+ // This should never happen since we already verified the publisher in
+ // getMatchesFor.
+ e { "App publisher changed between getMatchesFor and addMatchFor" }
+ throw AutofillPublisherChangedException(formOrigin)
+ }
+ val matchPreferences = context.matchPreferences(formOrigin)
+ val matchedFiles = matchPreferences.getStringSet(matchesKey(formOrigin), emptySet())!!.map { File(it) }
+ val newFiles = setOf(file.absoluteFile).union(matchedFiles)
+ if (newFiles.size > MAX_NUM_MATCHES) {
+ Toast.makeText(
+ context,
+ context.getString(R.string.oreo_autofill_max_matches_reached, MAX_NUM_MATCHES),
+ Toast.LENGTH_LONG
+ )
+ .show()
+ return
+ }
+ matchPreferences.edit { putStringSet(matchesKey(formOrigin), newFiles.map { it.absolutePath }.toSet()) }
+ storeFormOriginHash(context, formOrigin)
+ d { "Stored match for $formOrigin" }
+ }
- /**
- * Goes through all existing matches and updates their associated entries by using
- * [moveFromTo] as a lookup table and deleting the matches for files in [delete].
- */
- fun updateMatches(context: Context, moveFromTo: Map<File, File> = emptyMap(), delete: Collection<File> = emptyList()) {
- val deletePathList = delete.map { it.absolutePath }
- val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }
- .mapKeys { it.key.absolutePath }
- for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
- for ((key, value) in prefs.all) {
- if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
- // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
- // created with `putStringSet`.
- @Suppress("UNCHECKED_CAST")
- val oldMatches = value as? Set<String>
- if (oldMatches == null) {
- w { "Failed to read matches for $key" }
- continue
- }
- // Delete all matches for file locations that are going to be overwritten, then
- // transfer matches over to the files at their new locations.
- val newMatches =
- oldMatches.asSequence()
- .minus(deletePathList)
- .minus(oldNewPathMap.values)
- .map { match ->
- val newPath = oldNewPathMap[match] ?: return@map match
- d { "Updating match for $key: $match --> $newPath" }
- newPath
- }.toSet()
- if (newMatches != oldMatches)
- prefs.edit { putStringSet(key, newMatches) }
- }
- }
+ /**
+ * Goes through all existing matches and updates their associated entries by using [moveFromTo]
+ * as a lookup table and deleting the matches for files in [delete].
+ */
+ fun updateMatches(
+ context: Context,
+ moveFromTo: Map<File, File> = emptyMap(),
+ delete: Collection<File> = emptyList()
+ ) {
+ val deletePathList = delete.map { it.absolutePath }
+ val oldNewPathMap = moveFromTo.mapValues { it.value.absolutePath }.mapKeys { it.key.absolutePath }
+ for (prefs in listOf(context.autofillAppMatches, context.autofillWebMatches)) {
+ for ((key, value) in prefs.all) {
+ if (!key.startsWith(PREFERENCE_PREFIX_MATCHES)) continue
+ // We know that preferences starting with `PREFERENCE_PREFIX_MATCHES` were
+ // created with `putStringSet`.
+ @Suppress("UNCHECKED_CAST") val oldMatches = value as? Set<String>
+ if (oldMatches == null) {
+ w { "Failed to read matches for $key" }
+ continue
+ }
+ // Delete all matches for file locations that are going to be overwritten, then
+ // transfer matches over to the files at their new locations.
+ val newMatches =
+ oldMatches
+ .asSequence()
+ .minus(deletePathList)
+ .minus(oldNewPathMap.values)
+ .map { match ->
+ val newPath = oldNewPathMap[match] ?: return@map match
+ d { "Updating match for $key: $match --> $newPath" }
+ newPath
+ }
+ .toSet()
+ if (newMatches != oldMatches) prefs.edit { putStringSet(key, newMatches) }
}
+ }
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt
index b258e5df..6e1fe464 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillPreferences.kt
@@ -17,125 +17,128 @@ import java.io.File
import java.nio.file.Paths
enum class DirectoryStructure(val value: String) {
- EncryptedUsername("encrypted_username"),
- FileBased("file"),
- DirectoryBased("directory");
+ EncryptedUsername("encrypted_username"),
+ FileBased("file"),
+ DirectoryBased("directory");
- /**
- * Returns the username associated to [file], following the convention of the current
- * [DirectoryStructure].
- *
- * Examples:
- * - * --> null (EncryptedUsername)
- * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
- * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
- * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
- */
- fun getUsernameFor(file: File): String? = when (this) {
- EncryptedUsername -> null
- FileBased -> file.nameWithoutExtension
- DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
+ /**
+ * Returns the username associated to [file], following the convention of the current
+ * [DirectoryStructure].
+ *
+ * Examples:
+ * - * --> null (EncryptedUsername)
+ * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
+ * - work/example.org/john@doe.org/password.gpg --> john@doe.org (DirectoryBased)
+ * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
+ */
+ fun getUsernameFor(file: File): String? =
+ when (this) {
+ EncryptedUsername -> null
+ FileBased -> file.nameWithoutExtension
+ DirectoryBased -> file.parentFile?.name ?: file.nameWithoutExtension
}
- /**
- * Returns the origin identifier associated to [file], following the convention of the current
- * [DirectoryStructure].
- *
- * At least one of [DirectoryStructure.getIdentifierFor] and
- * [DirectoryStructure.getAccountPartFor] will always return a non-null result.
- *
- * Examples:
- * - work/example.org.gpg --> example.org (EncryptedUsername)
- * - work/example.org/john@doe.org.gpg --> example.org (FileBased)
- * - example.org.gpg --> example.org (FileBased, fallback)
- * - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
- * - Temporary PIN.gpg --> null (DirectoryBased)
- */
- fun getIdentifierFor(file: File): String? = when (this) {
- EncryptedUsername -> file.nameWithoutExtension
- FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
- DirectoryBased -> file.parentFile?.parent
+ /**
+ * Returns the origin identifier associated to [file], following the convention of the current
+ * [DirectoryStructure].
+ *
+ * At least one of [DirectoryStructure.getIdentifierFor] and
+ * [DirectoryStructure.getAccountPartFor] will always return a non-null result.
+ *
+ * Examples:
+ * - work/example.org.gpg --> example.org (EncryptedUsername)
+ * - work/example.org/john@doe.org.gpg --> example.org (FileBased)
+ * - example.org.gpg --> example.org (FileBased, fallback)
+ * - work/example.org/john@doe.org/password.gpg --> example.org (DirectoryBased)
+ * - Temporary PIN.gpg --> null (DirectoryBased)
+ */
+ fun getIdentifierFor(file: File): String? =
+ when (this) {
+ EncryptedUsername -> file.nameWithoutExtension
+ FileBased -> file.parentFile?.name ?: file.nameWithoutExtension
+ DirectoryBased -> file.parentFile?.parent
}
- /**
- * Returns the path components of [file] until right before the component that contains the
- * origin identifier according to the current [DirectoryStructure].
- *
- * Examples:
- * - work/example.org.gpg --> work (EncryptedUsername)
- * - work/example.org/john@doe.org.gpg --> work (FileBased)
- * - example.org/john@doe.org.gpg --> null (FileBased)
- * - john@doe.org.gpg --> null (FileBased)
- * - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
- * - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
- */
- fun getPathToIdentifierFor(file: File): String? = when (this) {
- EncryptedUsername -> file.parent
- FileBased -> file.parentFile?.parent
- DirectoryBased -> file.parentFile?.parentFile?.parent
+ /**
+ * Returns the path components of [file] until right before the component that contains the origin
+ * identifier according to the current [DirectoryStructure].
+ *
+ * Examples:
+ * - work/example.org.gpg --> work (EncryptedUsername)
+ * - work/example.org/john@doe.org.gpg --> work (FileBased)
+ * - example.org/john@doe.org.gpg --> null (FileBased)
+ * - john@doe.org.gpg --> null (FileBased)
+ * - work/example.org/john@doe.org/password.gpg --> work (DirectoryBased)
+ * - example.org/john@doe.org/password.gpg --> null (DirectoryBased)
+ */
+ fun getPathToIdentifierFor(file: File): String? =
+ when (this) {
+ EncryptedUsername -> file.parent
+ FileBased -> file.parentFile?.parent
+ DirectoryBased -> file.parentFile?.parentFile?.parent
}
- /**
- * Returns the path component of [file] following the origin identifier according to the current
- * [DirectoryStructure] (without file extension).
- *
- * At least one of [DirectoryStructure.getIdentifierFor] and
- * [DirectoryStructure.getAccountPartFor] will always return a non-null result.
- *
- * Examples:
- * - * --> null (EncryptedUsername)
- * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
- * - example.org.gpg --> null (FileBased, fallback)
- * - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
- * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
- */
- fun getAccountPartFor(file: File): String? = when (this) {
- EncryptedUsername -> null
- FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
- DirectoryBased -> file.parentFile?.let { parentFile ->
- "${parentFile.name}/${file.nameWithoutExtension}"
- } ?: file.nameWithoutExtension
+ /**
+ * Returns the path component of [file] following the origin identifier according to the current
+ * [DirectoryStructure](without file extension).
+ *
+ * At least one of [DirectoryStructure.getIdentifierFor] and
+ * [DirectoryStructure.getAccountPartFor] will always return a non-null result.
+ *
+ * Examples:
+ * - * --> null (EncryptedUsername)
+ * - work/example.org/john@doe.org.gpg --> john@doe.org (FileBased)
+ * - example.org.gpg --> null (FileBased, fallback)
+ * - work/example.org/john@doe.org/password.gpg --> john@doe.org/password (DirectoryBased)
+ * - Temporary PIN.gpg --> Temporary PIN (DirectoryBased, fallback)
+ */
+ fun getAccountPartFor(file: File): String? =
+ when (this) {
+ EncryptedUsername -> null
+ FileBased -> file.nameWithoutExtension.takeIf { file.parentFile != null }
+ DirectoryBased -> file.parentFile?.let { parentFile -> "${parentFile.name}/${file.nameWithoutExtension}" }
+ ?: file.nameWithoutExtension
}
- @RequiresApi(Build.VERSION_CODES.O)
- fun getSaveFolderName(sanitizedIdentifier: String, username: String?) = when (this) {
- EncryptedUsername -> "/"
- FileBased -> sanitizedIdentifier
- DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
+ @RequiresApi(Build.VERSION_CODES.O)
+ fun getSaveFolderName(sanitizedIdentifier: String, username: String?) =
+ when (this) {
+ EncryptedUsername -> "/"
+ FileBased -> sanitizedIdentifier
+ DirectoryBased -> Paths.get(sanitizedIdentifier, username ?: "username").toString()
}
- fun getSaveFileName(username: String?, identifier: String) = when (this) {
- EncryptedUsername -> identifier
- FileBased -> username
- DirectoryBased -> "password"
+ fun getSaveFileName(username: String?, identifier: String) =
+ when (this) {
+ EncryptedUsername -> identifier
+ FileBased -> username
+ DirectoryBased -> "password"
}
- companion object {
+ companion object {
- val DEFAULT = FileBased
+ val DEFAULT = FileBased
- private val reverseMap = values().associateBy { it.value }
- fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
- }
+ private val reverseMap = values().associateBy { it.value }
+ fun fromValue(value: String?) = if (value != null) reverseMap[value] ?: DEFAULT else DEFAULT
+ }
}
object AutofillPreferences {
- fun directoryStructure(context: Context): DirectoryStructure {
- val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
- return DirectoryStructure.fromValue(value)
- }
+ fun directoryStructure(context: Context): DirectoryStructure {
+ val value = context.sharedPrefs.getString(PreferenceKeys.OREO_AUTOFILL_DIRECTORY_STRUCTURE)
+ return DirectoryStructure.fromValue(value)
+ }
- fun credentialsFromStoreEntry(
- context: Context,
- file: File,
- entry: PasswordEntry,
- directoryStructure: DirectoryStructure
- ): Credentials {
- // Always give priority to a username stored in the encrypted extras
- val username = entry.username
- ?: directoryStructure.getUsernameFor(file)
- ?: context.getDefaultUsername()
- return Credentials(username, entry.password, entry.calculateTotpCode())
- }
+ fun credentialsFromStoreEntry(
+ context: Context,
+ file: File,
+ entry: PasswordEntry,
+ directoryStructure: DirectoryStructure
+ ): Credentials {
+ // Always give priority to a username stored in the encrypted extras
+ val username = entry.username ?: directoryStructure.getUsernameFor(file) ?: context.getDefaultUsername()
+ return Credentials(username, entry.password, entry.calculateTotpCode())
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
index d5e16a26..d8126438 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillResponseBuilder.kt
@@ -30,176 +30,178 @@ import java.io.File
@RequiresApi(Build.VERSION_CODES.O)
class AutofillResponseBuilder(form: FillableForm) {
- private val formOrigin = form.formOrigin
- private val scenario = form.scenario
- private val ignoredIds = form.ignoredIds
- private val saveFlags = form.saveFlags
- private val clientState = form.toClientState()
-
- // We do not offer save when the only relevant field is a username field or there is no field.
- private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
- private val canBeSaved = saveFlags != null && scenarioSupportsSave
-
- private fun makeIntentDataset(
- context: Context,
- action: AutofillAction,
- intentSender: IntentSender,
- metadata: DatasetMetadata,
- ): Dataset {
- return Dataset.Builder(makeRemoteView(context, metadata)).run {
- fillWith(scenario, action, credentials = null)
- setAuthentication(intentSender)
- build()
- }
- }
-
- private fun makeMatchDataset(context: Context, file: File): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
- val metadata = makeFillMatchMetadata(context, file)
- val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
- return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
- }
-
-
- private fun makeSearchDataset(context: Context): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
- val metadata = makeSearchAndFillMetadata(context)
- val intentSender =
- AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
- return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
+ private val formOrigin = form.formOrigin
+ private val scenario = form.scenario
+ private val ignoredIds = form.ignoredIds
+ private val saveFlags = form.saveFlags
+ private val clientState = form.toClientState()
+
+ // We do not offer save when the only relevant field is a username field or there is no field.
+ private val scenarioSupportsSave = scenario.hasPasswordFieldsToSave
+ private val canBeSaved = saveFlags != null && scenarioSupportsSave
+
+ private fun makeIntentDataset(
+ context: Context,
+ action: AutofillAction,
+ intentSender: IntentSender,
+ metadata: DatasetMetadata,
+ ): Dataset {
+ return Dataset.Builder(makeRemoteView(context, metadata)).run {
+ fillWith(scenario, action, credentials = null)
+ setAuthentication(intentSender)
+ build()
}
-
- private fun makeGenerateDataset(context: Context): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
- val metadata = makeGenerateAndFillMetadata(context)
- val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
- return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata)
+ }
+
+ private fun makeMatchDataset(context: Context, file: File): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Match)) return null
+ val metadata = makeFillMatchMetadata(context, file)
+ val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context)
+ return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
+ }
+
+ private fun makeSearchDataset(context: Context): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Search)) return null
+ val metadata = makeSearchAndFillMetadata(context)
+ val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin)
+ return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata)
+ }
+
+ private fun makeGenerateDataset(context: Context): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.Generate)) return null
+ val metadata = makeGenerateAndFillMetadata(context)
+ val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin)
+ return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata)
+ }
+
+ private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
+ if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
+ if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
+ val metadata = makeFillOtpFromSmsMetadata(context)
+ val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
+ return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata)
+ }
+
+ private fun makePublisherChangedDataset(
+ context: Context,
+ publisherChangedException: AutofillPublisherChangedException,
+ ): Dataset {
+ val metadata = makeWarningMetadata(context)
+ // If the user decides to trust the new publisher, they can choose reset the list of
+ // matches. In this case we need to immediately show a new `FillResponse` as if the app were
+ // autofilled for the first time. This `FillResponse` needs to be returned as a result from
+ // `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
+ val fillResponseAfterReset = makeFillResponse(context, emptyList())
+ val intentSender =
+ AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
+ context,
+ publisherChangedException,
+ fillResponseAfterReset
+ )
+ return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
+ }
+
+ private fun makePublisherChangedResponse(
+ context: Context,
+ publisherChangedException: AutofillPublisherChangedException
+ ): FillResponse {
+ return FillResponse.Builder().run {
+ addDataset(makePublisherChangedDataset(context, publisherChangedException))
+ setIgnoredIds(*ignoredIds.toTypedArray())
+ build()
}
-
- private fun makeFillOtpFromSmsDataset(context: Context): Dataset? {
- if (!scenario.hasFieldsToFillOn(AutofillAction.FillOtpFromSms)) return null
- if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null
- val metadata = makeFillOtpFromSmsMetadata(context)
- val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context)
- return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata)
+ }
+
+ // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
+ // See:
+ // https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
+ private fun makeSaveInfo(): SaveInfo? {
+ if (!canBeSaved) return null
+ check(saveFlags != null)
+ val idsToSave = scenario.fieldsToSave.toTypedArray()
+ if (idsToSave.isEmpty()) return null
+ var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
+ if (scenario.hasUsername) {
+ saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
}
-
- private fun makePublisherChangedDataset(
- context: Context,
- publisherChangedException: AutofillPublisherChangedException,
- ): Dataset {
- val metadata = makeWarningMetadata(context)
- // If the user decides to trust the new publisher, they can choose reset the list of
- // matches. In this case we need to immediately show a new `FillResponse` as if the app were
- // autofilled for the first time. This `FillResponse` needs to be returned as a result from
- // `AutofillPublisherChangedActivity`, which is why we create and pass it on here.
- val fillResponseAfterReset = makeFillResponse(context, emptyList())
- val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender(
- context, publisherChangedException, fillResponseAfterReset
- )
- return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata)
+ return SaveInfo.Builder(saveDataTypes, idsToSave).run {
+ setFlags(saveFlags)
+ build()
}
-
- private fun makePublisherChangedResponse(
- context: Context,
- publisherChangedException: AutofillPublisherChangedException
- ): FillResponse {
- return FillResponse.Builder().run {
- addDataset(makePublisherChangedDataset(context, publisherChangedException))
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
+ }
+
+ private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
+ var datasetCount = 0
+ return FillResponse.Builder().run {
+ for (file in matchedFiles) {
+ makeMatchDataset(context, file)?.let {
+ datasetCount++
+ addDataset(it)
}
+ }
+ makeGenerateDataset(context)?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeFillOtpFromSmsDataset(context)?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ makeSearchDataset(context)?.let {
+ datasetCount++
+ addDataset(it)
+ }
+ if (datasetCount == 0) return null
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ setHeader(
+ makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true)))
+ )
+ }
+ makeSaveInfo()?.let { setSaveInfo(it) }
+ setClientState(clientState)
+ setIgnoredIds(*ignoredIds.toTypedArray())
+ build()
}
-
- // TODO: Support multi-step authentication flows in apps via FLAG_DELAY_SAVE
- // See: https://developer.android.com/reference/android/service/autofill/SaveInfo#FLAG_DELAY_SAVE
- private fun makeSaveInfo(): SaveInfo? {
- if (!canBeSaved) return null
- check(saveFlags != null)
- val idsToSave = scenario.fieldsToSave.toTypedArray()
- if (idsToSave.isEmpty()) return null
- var saveDataTypes = SaveInfo.SAVE_DATA_TYPE_PASSWORD
- if (scenario.hasUsername) {
- saveDataTypes = saveDataTypes or SaveInfo.SAVE_DATA_TYPE_USERNAME
+ }
+
+ /** Creates and returns a suitable [FillResponse] to the Autofill framework. */
+ fun fillCredentials(context: Context, callback: FillCallback) {
+ AutofillMatcher.getMatchesFor(context, formOrigin)
+ .fold(
+ success = { matchedFiles -> callback.onSuccess(makeFillResponse(context, matchedFiles)) },
+ failure = { e ->
+ e(e)
+ callback.onSuccess(makePublisherChangedResponse(context, e))
}
- return SaveInfo.Builder(saveDataTypes, idsToSave).run {
- setFlags(saveFlags)
- build()
- }
- }
+ )
+ }
- private fun makeFillResponse(context: Context, matchedFiles: List<File>): FillResponse? {
- var datasetCount = 0
- return FillResponse.Builder().run {
- for (file in matchedFiles) {
- makeMatchDataset(context, file)?.let {
- datasetCount++
- addDataset(it)
- }
- }
- makeGenerateDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- makeFillOtpFromSmsDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- makeSearchDataset(context)?.let {
- datasetCount++
- addDataset(it)
- }
- if (datasetCount == 0) return null
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- setHeader(makeRemoteView(context, makeHeaderMetadata(formOrigin.getPrettyIdentifier(context, untrusted = true))))
- }
- makeSaveInfo()?.let { setSaveInfo(it) }
- setClientState(clientState)
- setIgnoredIds(*ignoredIds.toTypedArray())
- build()
- }
- }
+ companion object {
- /**
- * Creates and returns a suitable [FillResponse] to the Autofill framework.
- */
- fun fillCredentials(context: Context, callback: FillCallback) {
- AutofillMatcher.getMatchesFor(context, formOrigin).fold(
- success = { matchedFiles ->
- callback.onSuccess(makeFillResponse(context, matchedFiles))
- },
- failure = { e ->
- e(e)
- callback.onSuccess(makePublisherChangedResponse(context, e))
- }
- )
- }
-
- companion object {
-
- fun makeFillInDataset(
- context: Context,
- credentials: Credentials,
- clientState: Bundle,
- action: AutofillAction
- ): Dataset {
- val scenario = AutofillScenario.fromClientState(clientState)
- // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
- // though they are rarely shown.
- // FIXME: We should clone the original dataset here and add the credentials to be filled
- // in. Otherwise, the entry in the cached list of datasets will be overwritten by the
- // fill-in dataset without any visual representation. This causes it to be missing from
- // the Autofill suggestions shown after the user clears the filled out form fields.
- val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- Dataset.Builder()
- } else {
- Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))
- }
- return builder.run {
- if (scenario != null) fillWith(scenario, action, credentials)
- else e { "Failed to recover scenario from client state" }
- build()
- }
+ fun makeFillInDataset(
+ context: Context,
+ credentials: Credentials,
+ clientState: Bundle,
+ action: AutofillAction
+ ): Dataset {
+ val scenario = AutofillScenario.fromClientState(clientState)
+ // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even
+ // though they are rarely shown.
+ // FIXME: We should clone the original dataset here and add the credentials to be filled
+ // in. Otherwise, the entry in the cached list of datasets will be overwritten by the
+ // fill-in dataset without any visual representation. This causes it to be missing from
+ // the Autofill suggestions shown after the user clears the filled out form fields.
+ val builder =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ Dataset.Builder()
+ } else {
+ Dataset.Builder(makeRemoteView(context, makeEmptyMetadata()))
}
+ return builder.run {
+ if (scenario != null) fillWith(scenario, action, credentials)
+ else e { "Failed to recover scenario from client state" }
+ build()
+ }
}
+ }
}
diff --git a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt
index e460dd35..46f7d821 100644
--- a/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt
+++ b/app/src/main/java/dev/msfjarvis/aps/util/autofill/AutofillViewUtils.kt
@@ -26,88 +26,74 @@ import java.io.File
data class DatasetMetadata(val title: String, val subtitle: String?, @DrawableRes val iconRes: Int)
fun makeRemoteView(context: Context, metadata: DatasetMetadata): RemoteViews {
- return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
- setTextViewText(R.id.title, metadata.title)
- if (metadata.subtitle != null) {
- setTextViewText(R.id.summary, metadata.subtitle)
- } else {
- setViewVisibility(R.id.summary, View.GONE)
- }
- if (metadata.iconRes != Resources.ID_NULL) {
- setImageViewResource(R.id.icon, metadata.iconRes)
- } else {
- setViewVisibility(R.id.icon, View.GONE)
- }
+ return RemoteViews(context.packageName, R.layout.oreo_autofill_dataset).apply {
+ setTextViewText(R.id.title, metadata.title)
+ if (metadata.subtitle != null) {
+ setTextViewText(R.id.summary, metadata.subtitle)
+ } else {
+ setViewVisibility(R.id.summary, View.GONE)
}
+ if (metadata.iconRes != Resources.ID_NULL) {
+ setImageViewResource(R.id.icon, metadata.iconRes)
+ } else {
+ setViewVisibility(R.id.icon, View.GONE)
+ }
+ }
}
@SuppressLint("RestrictedApi")
-fun makeInlinePresentation(context: Context, imeSpec: InlinePresentationSpec, metadata: DatasetMetadata): InlinePresentation? {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R)
- return null
+fun makeInlinePresentation(
+ context: Context,
+ imeSpec: InlinePresentationSpec,
+ metadata: DatasetMetadata
+): InlinePresentation? {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
- if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style))
- return null
+ if (UiVersions.INLINE_UI_VERSION_1 !in UiVersions.getVersions(imeSpec.style)) return null
- val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0)
- val slice = InlineSuggestionUi.newContentBuilder(launchIntent).run {
- setTitle(metadata.title)
- if (metadata.subtitle != null)
- setSubtitle(metadata.subtitle)
- setContentDescription(if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title)
- setStartIcon(Icon.createWithResource(context, metadata.iconRes))
- build().slice
+ val launchIntent = PendingIntent.getActivity(context, 0, Intent(context, PasswordStore::class.java), 0)
+ val slice =
+ InlineSuggestionUi.newContentBuilder(launchIntent).run {
+ setTitle(metadata.title)
+ if (metadata.subtitle != null) setSubtitle(metadata.subtitle)
+ setContentDescription(
+ if (metadata.subtitle != null) "${metadata.title} - ${metadata.subtitle}" else metadata.title
+ )
+ setStartIcon(Icon.createWithResource(context, metadata.iconRes))
+ build().slice
}
- return InlinePresentation(slice, imeSpec, false)
+ return InlinePresentation(slice, imeSpec, false)
}
-
fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata {
- val directoryStructure = AutofillPreferences.directoryStructure(context)
- val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
- val title = directoryStructure.getIdentifierFor(relativeFile)
- ?: directoryStructure.getAccountPartFor(relativeFile)!!
- val subtitle = directoryStructure.getAccountPartFor(relativeFile)
- return DatasetMetadata(
- title,
- subtitle,
- R.drawable.ic_person_black_24dp
- )
+ val directoryStructure = AutofillPreferences.directoryStructure(context)
+ val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory())
+ val title = directoryStructure.getIdentifierFor(relativeFile) ?: directoryStructure.getAccountPartFor(relativeFile)!!
+ val subtitle = directoryStructure.getAccountPartFor(relativeFile)
+ return DatasetMetadata(title, subtitle, R.drawable.ic_person_black_24dp)
}
-fun makeSearchAndFillMetadata(context: Context) = DatasetMetadata(
- context.getString(R.string.oreo_autofill_search_in_store),
- null,
- R.drawable.ic_search_black_24dp
-)
+fun makeSearchAndFillMetadata(context: Context) =
+ DatasetMetadata(context.getString(R.string.oreo_autofill_search_in_store), null, R.drawable.ic_search_black_24dp)
-fun makeGenerateAndFillMetadata(context: Context) = DatasetMetadata(
+fun makeGenerateAndFillMetadata(context: Context) =
+ DatasetMetadata(
context.getString(R.string.oreo_autofill_generate_password),
null,
R.drawable.ic_autofill_new_password
-)
+ )
-fun makeFillOtpFromSmsMetadata(context: Context) = DatasetMetadata(
- context.getString(R.string.oreo_autofill_fill_otp_from_sms),
- null,
- R.drawable.ic_autofill_sms
-)
+fun makeFillOtpFromSmsMetadata(context: Context) =
+ DatasetMetadata(context.getString(R.string.oreo_autofill_fill_otp_from_sms), null, R.drawable.ic_autofill_sms)
-fun makeEmptyMetadata() = DatasetMetadata(
- "PLACEHOLDER",
- "PLACEHOLDER",
- R.mipmap.ic_launcher
-)
+fun makeEmptyMetadata() = DatasetMetadata("PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher)
-fun makeWarningMetadata(context: Context) = DatasetMetadata(
+fun makeWarningMetadata(context: Context) =
+ DatasetMetadata(
context.getString(R.string.oreo_autofill_warning_publisher_dataset_title),
context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary),
R.drawable.ic_warning_red_24dp
-)
+ )
-fun makeHeaderMetadata(title: String) = DatasetMetadata(
- title,
- null,
- 0
-)
+fun makeHeaderMetadata(title: String) = DatasetMetadata(title, null, 0)