summaryrefslogtreecommitdiff
path: root/app/src/main/java
diff options
context:
space:
mode:
authorFabian Henneke <FabianHenneke@users.noreply.github.com>2020-11-02 20:25:37 +0100
committerGitHub <noreply@github.com>2020-11-02 20:25:37 +0100
commit1d13a1fbd6580c0d28c90579ade96e0a93e17c92 (patch)
tree89cf7824cb7938bb107cca37124dc91354b19087 /app/src/main/java
parentcff8d41c91a73371f7eb65b08730fa548ce510a4 (diff)
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 <me@msfjarvis.dev> * Remove unused parameter Signed-off-by: Harsh Shandilya <me@msfjarvis.dev> Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillResponseBuilder.kt105
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/AutofillViewUtils.kt132
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/OreoAutofillService.kt8
-rw-r--r--app/src/main/java/com/zeapo/pwdstore/autofill/oreo/ui/AutofillFilterActivity.kt7
4 files changed, 169 insertions, 83 deletions
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<File>): FillResponse? {
- var hasDataset = false
+ private fun makeFillResponse(context: Context, inlineSuggestionsRequest: InlineSuggestionsRequest?, matchedFiles: List<File>): FillResponse? {
+ var datasetCount = 0
+ val imeSpecs: List<InlinePresentationSpec> = 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