diff options
Diffstat (limited to 'app/src/main/java/dev/msfjarvis/aps/util/autofill')
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) |
