From 1d13a1fbd6580c0d28c90579ade96e0a93e17c92 Mon Sep 17 00:00:00 2001 From: Fabian Henneke Date: Mon, 2 Nov 2020 20:25:37 +0100 Subject: Improve Autofill UI and enable inline presentations (#1181) * Improve Autofill UI and enable inline presentations Improves the Autofill UI in the following ways: * Add support for Android 11 inline presentations of Autofill datasets. * Instead of showing the identifier (app name or web origin) of the current app on top of every Autofill dataset, it is now shown 1) as a header dataset on Android 9 and 10 as well as 2) at the top of the search activity on all supported versions of Android. Rationale: The identifier is only used in trust decisions when choosing an existing entry to fill and should feature prominently in that view, not elsewhere. * Show the actual identifier part of a matched entry's path, which may differ from the identifier of the matched app/website. * Slightly tweak the labels of Search/Generate Autofill actions to indicate that a) this is about entries and b) the user may skip the generation of a password and supply a custom one as well. * Suppress lint error * Address review comments * Add a fixme about properly handling fill-in datasets * CHANGELOG: add entry for inline presentation Signed-off-by: Harsh Shandilya * Remove unused parameter Signed-off-by: Harsh Shandilya Co-authored-by: Harsh Shandilya --- .../autofill/oreo/AutofillResponseBuilder.kt | 105 ++++++++++------ .../pwdstore/autofill/oreo/AutofillViewUtils.kt | 132 ++++++++++++++------- .../pwdstore/autofill/oreo/OreoAutofillService.kt | 8 +- .../autofill/oreo/ui/AutofillFilterActivity.kt | 7 ++ 4 files changed, 169 insertions(+), 83 deletions(-) (limited to 'app/src/main/java/com/zeapo') diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt index ec3d2b77..2c62f935 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt @@ -12,7 +12,8 @@ import android.service.autofill.Dataset import android.service.autofill.FillCallback import android.service.autofill.FillResponse import android.service.autofill.SaveInfo -import android.widget.RemoteViews +import android.view.inputmethod.InlineSuggestionsRequest +import android.widget.inline.InlinePresentationSpec import androidx.annotation.RequiresApi import com.github.ajalt.timberkt.e import com.github.androidpasswordstore.autofillparser.AutofillAction @@ -41,70 +42,85 @@ class AutofillResponseBuilder(form: FillableForm) { scenario.fieldsToSave.minus(listOfNotNull(scenario.username)).isNotEmpty() private val canBeSaved = saveFlags != null && scenarioSupportsSave - private fun makePlaceholderDataset( - remoteView: RemoteViews, + private fun makeIntentDataset( + context: Context, + action: AutofillAction, intentSender: IntentSender, - action: AutofillAction + metadata: DatasetMetadata, + imeSpec: InlinePresentationSpec?, ): Dataset { - return Dataset.Builder(remoteView).run { + return Dataset.Builder(makeRemoteView(context, metadata)).run { fillWith(scenario, action, credentials = null) setAuthentication(intentSender) + if (imeSpec != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val inlinePresentation = makeInlinePresentation(context, imeSpec, metadata) + if (inlinePresentation != null) { + setInlinePresentation(inlinePresentation) + } + } build() } } - private fun makeMatchDataset(context: Context, file: File): Dataset? { + private fun makeMatchDataset(context: Context, file: File, imeSpec: InlinePresentationSpec?): Dataset? { if (scenario.fieldsToFillOn(AutofillAction.Match).isEmpty()) return null - val remoteView = makeFillMatchRemoteView(context, file, formOrigin) + val metadata = makeFillMatchMetadata(context, file) val intentSender = AutofillDecryptActivity.makeDecryptFileIntentSender(file, context) - return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) } - private fun makeSearchDataset(context: Context): Dataset? { + private fun makeSearchDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { if (scenario.fieldsToFillOn(AutofillAction.Search).isEmpty()) return null - val remoteView = makeSearchAndFillRemoteView(context, formOrigin) + val metadata = makeSearchAndFillMetadata(context) val intentSender = AutofillFilterView.makeMatchAndDecryptFileIntentSender(context, formOrigin) - return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Search) + return makeIntentDataset(context, AutofillAction.Search, intentSender, metadata, imeSpec) } - private fun makeGenerateDataset(context: Context): Dataset? { + private fun makeGenerateDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { if (scenario.fieldsToFillOn(AutofillAction.Generate).isEmpty()) return null - val remoteView = makeGenerateAndFillRemoteView(context, formOrigin) + val metadata = makeGenerateAndFillMetadata(context) val intentSender = AutofillSaveActivity.makeSaveIntentSender(context, null, formOrigin) - return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Generate) + return makeIntentDataset(context, AutofillAction.Generate, intentSender, metadata, imeSpec) } - private fun makeFillOtpFromSmsDataset(context: Context): Dataset? { + private fun makeFillOtpFromSmsDataset(context: Context, imeSpec: InlinePresentationSpec?): Dataset? { if (scenario.fieldsToFillOn(AutofillAction.FillOtpFromSms).isEmpty()) return null if (!AutofillSmsActivity.shouldOfferFillFromSms(context)) return null - val remoteView = makeFillOtpFromSmsRemoteView(context, formOrigin) + val metadata = makeFillOtpFromSmsMetadata(context) val intentSender = AutofillSmsActivity.makeFillOtpFromSmsIntentSender(context) - return makePlaceholderDataset(remoteView, intentSender, AutofillAction.FillOtpFromSms) + return makeIntentDataset(context, AutofillAction.FillOtpFromSms, intentSender, metadata, imeSpec) } private fun makePublisherChangedDataset( context: Context, - publisherChangedException: AutofillPublisherChangedException + publisherChangedException: AutofillPublisherChangedException, + imeSpec: InlinePresentationSpec? ): Dataset { - val remoteView = makeWarningRemoteView(context) + 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 fillResponseAfterReset = makeFillResponse(context, null, emptyList()) val intentSender = AutofillPublisherChangedActivity.makePublisherChangedIntentSender( context, publisherChangedException, fillResponseAfterReset ) - return makePlaceholderDataset(remoteView, intentSender, AutofillAction.Match) + return makeIntentDataset(context, AutofillAction.Match, intentSender, metadata, imeSpec) } private fun makePublisherChangedResponse( context: Context, + inlineSuggestionsRequest: InlineSuggestionsRequest?, publisherChangedException: AutofillPublisherChangedException ): FillResponse { + val imeSpec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + inlineSuggestionsRequest?.inlinePresentationSpecs?.firstOrNull() + } else { + null + } return FillResponse.Builder().run { - addDataset(makePublisherChangedDataset(context, publisherChangedException)) + addDataset(makePublisherChangedDataset(context, publisherChangedException, imeSpec)) setIgnoredIds(*ignoredIds.toTypedArray()) build() } @@ -127,28 +143,36 @@ class AutofillResponseBuilder(form: FillableForm) { } } - private fun makeFillResponse(context: Context, matchedFiles: List): FillResponse? { - var hasDataset = false + private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List): FillResponse? { + var datasetCount = 0 + val imeSpecs: List = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + inlineSuggestionsRequest?.inlinePresentationSpecs + } else { + null + } ?: emptyList() return FillResponse.Builder().run { for (file in matchedFiles) { - makeMatchDataset(context, file)?.let { - hasDataset = true + makeMatchDataset(context, file, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ addDataset(it) } } - makeSearchDataset(context)?.let { - hasDataset = true + makeSearchDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ addDataset(it) } - makeGenerateDataset(context)?.let { - hasDataset = true + makeGenerateDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ addDataset(it) } - makeFillOtpFromSmsDataset(context)?.let { - hasDataset = true + makeFillOtpFromSmsDataset(context, imeSpecs.getOrNull(datasetCount))?.let { + datasetCount++ addDataset(it) } - if (!hasDataset) return null + 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()) @@ -159,14 +183,14 @@ class AutofillResponseBuilder(form: FillableForm) { /** * Creates and returns a suitable [FillResponse] to the Autofill framework. */ - fun fillCredentials(context: Context, callback: FillCallback) { + fun fillCredentials(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, callback: FillCallback) { AutofillMatcher.getMatchesFor(context, formOrigin).fold( success = { matchedFiles -> - callback.onSuccess(makeFillResponse(context, matchedFiles)) + callback.onSuccess(makeFillResponse(context, inlineSuggestionsRequest, matchedFiles)) }, failure = { e -> e(e) - callback.onSuccess(makePublisherChangedResponse(context, e)) + callback.onSuccess(makePublisherChangedResponse(context, inlineSuggestionsRequest, e)) } ) } @@ -178,14 +202,17 @@ class AutofillResponseBuilder(form: FillableForm) { clientState: Bundle, action: AutofillAction ): Dataset { - val remoteView = makePlaceholderRemoteView(context) val scenario = AutofillScenario.fromBundle(clientState) // Before Android P, Datasets used for fill-in had to come with a RemoteViews, even - // though they are never shown. + // 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(remoteView) + Dataset.Builder(makeRemoteView(context, makeEmptyMetadata())) } return builder.run { if (scenario != null) fillWith(scenario, action, credentials) diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt index 5e8061a2..49e0d3e3 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt @@ -4,64 +4,110 @@ */ package com.zeapo.pwdstore.autofill.oreo +import android.annotation.SuppressLint +import android.app.PendingIntent import android.content.Context +import android.content.Intent +import android.content.res.Resources +import android.graphics.drawable.Icon +import android.os.Build +import android.service.autofill.InlinePresentation +import android.view.View import android.widget.RemoteViews -import com.github.androidpasswordstore.autofillparser.FormOrigin +import android.widget.inline.InlinePresentationSpec +import androidx.annotation.DrawableRes +import androidx.autofill.inline.UiVersions +import androidx.autofill.inline.v1.InlineSuggestionUi +import com.zeapo.pwdstore.PasswordStore import com.zeapo.pwdstore.R import com.zeapo.pwdstore.utils.PasswordRepository import java.io.File -private fun makeRemoteView( - context: Context, - title: String, - summary: String, - iconRes: Int -): RemoteViews { +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, title) - setTextViewText(R.id.summary, summary) - setImageViewResource(R.id.icon, iconRes) + 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) + } } } -fun makeFillMatchRemoteView(context: Context, file: File, formOrigin: FormOrigin): RemoteViews { - val title = formOrigin.getPrettyIdentifier(context, untrusted = false) +@SuppressLint("RestrictedApi") +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 + + 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) +} + + +fun makeFillMatchMetadata(context: Context, file: File): DatasetMetadata { val directoryStructure = AutofillPreferences.directoryStructure(context) val relativeFile = file.relativeTo(PasswordRepository.getRepositoryDirectory()) - val summary = directoryStructure.getUsernameFor(relativeFile) - ?: directoryStructure.getPathToIdentifierFor(relativeFile) ?: "" - val iconRes = R.drawable.ic_person_black_24dp - return makeRemoteView(context, title, summary, iconRes) + val title = directoryStructure.getIdentifierFor(relativeFile) + ?: directoryStructure.getAccountPartFor(relativeFile)!! + val subtitle = directoryStructure.getAccountPartFor(relativeFile) + return DatasetMetadata( + title, + subtitle, + R.drawable.ic_person_black_24dp + ) } -fun makeSearchAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews { - val title = formOrigin.getPrettyIdentifier(context, untrusted = true) - val summary = context.getString(R.string.oreo_autofill_search_in_store) - val iconRes = R.drawable.ic_search_black_24dp - return makeRemoteView(context, title, summary, iconRes) -} +fun makeSearchAndFillMetadata(context: Context) = DatasetMetadata( + context.getString(R.string.oreo_autofill_search_in_store), + null, + R.drawable.ic_search_black_24dp +) -fun makeGenerateAndFillRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews { - val title = formOrigin.getPrettyIdentifier(context, untrusted = true) - val summary = context.getString(R.string.oreo_autofill_generate_password) - val iconRes = R.drawable.ic_autofill_new_password - return makeRemoteView(context, title, summary, iconRes) -} +fun makeGenerateAndFillMetadata(context: Context) = DatasetMetadata( + context.getString(R.string.oreo_autofill_generate_password), + null, + R.drawable.ic_autofill_new_password +) -fun makeFillOtpFromSmsRemoteView(context: Context, formOrigin: FormOrigin): RemoteViews { - val title = formOrigin.getPrettyIdentifier(context, untrusted = true) - val summary = context.getString(R.string.oreo_autofill_fill_otp_from_sms) - val iconRes = R.drawable.ic_autofill_sms - return makeRemoteView(context, title, summary, iconRes) -} +fun makeFillOtpFromSmsMetadata(context: Context) = DatasetMetadata( + context.getString(R.string.oreo_autofill_fill_otp_from_sms), + null, + R.drawable.ic_autofill_sms +) -fun makePlaceholderRemoteView(context: Context): RemoteViews { - return makeRemoteView(context, "PLACEHOLDER", "PLACEHOLDER", R.mipmap.ic_launcher) -} +fun makeEmptyMetadata() = DatasetMetadata( + "PLACEHOLDER", + "PLACEHOLDER", + R.mipmap.ic_launcher +) -fun makeWarningRemoteView(context: Context): RemoteViews { - val title = context.getString(R.string.oreo_autofill_warning_publisher_dataset_title) - val summary = context.getString(R.string.oreo_autofill_warning_publisher_dataset_summary) - val iconRes = R.drawable.ic_warning_red_24dp - return makeRemoteView(context, title, summary, iconRes) -} +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 +) diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt index ecec6747..10831cc5 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt @@ -86,7 +86,13 @@ class OreoAutofillService : AutofillService() { callback.onSuccess(null) return } - AutofillResponseBuilder(formToFill).fillCredentials(this, callback) + val inlineSuggestionsRequest = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + request.inlineSuggestionsRequest + } else { + null + } + AutofillResponseBuilder(formToFill).fillCredentials(this, inlineSuggestionsRequest, callback) } override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { diff --git a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt index f22c6596..25919ad5 100644 --- a/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt +++ b/app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt @@ -162,6 +162,13 @@ class AutofillFilterView : AppCompatActivity() { setText(initialSearch, TextView.BufferType.EDITABLE) addTextChangedListener { updateSearch() } } + origin.text = buildSpannedString { + append(getString(R.string.oreo_autofill_select_and_fill_into)) + append("\n") + bold { + append(formOrigin.getPrettyIdentifier(applicationContext, untrusted = true)) + } + } strictDomainSearch.apply { visibility = if (formOrigin is FormOrigin.Web) View.VISIBLE else View.GONE isChecked = formOrigin is FormOrigin.Web -- cgit v1.2.3