Add Android inline keyboard autofill suggestions (#1637)

This commit is contained in:
Leendert de Borst
2026-05-26 15:28:16 +02:00
committed by Leendert de Borst
parent 38f3a1359e
commit b78b81d3f6
4 changed files with 303 additions and 29 deletions

View File

@@ -187,6 +187,9 @@ dependencies {
// Add biometric dependency for credential management
implementation("androidx.biometric:biometric:1.1.0")
// Add androidx.autofill for inline suggestion (keyboard) support
implementation("androidx.autofill:autofill:1.1.0")
// Add Credential Manager dependencies for passkey support (Android 14+)
implementation("androidx.credentials:credentials:1.6.0-beta02")

View File

@@ -11,6 +11,7 @@ package net.aliasvault.app.autofill
import android.app.PendingIntent
import android.content.Intent
import android.graphics.Typeface
import android.os.Build
import android.os.CancellationSignal
import android.service.autofill.AutofillService
import android.service.autofill.Dataset
@@ -27,6 +28,7 @@ import android.widget.RemoteViews
import net.aliasvault.app.R
import net.aliasvault.app.autofill.utils.AutofillDatasetBuilder
import net.aliasvault.app.autofill.utils.FieldFinder
import net.aliasvault.app.autofill.utils.InlinePresentationHelper
import net.aliasvault.app.autofill.utils.RustItemMatcher
import net.aliasvault.app.vaultstore.VaultStore
import net.aliasvault.app.vaultstore.interfaces.ItemOperationCallback
@@ -78,7 +80,8 @@ class AutofillService : AutofillService() {
safeCallback()
return
}
launchActivityForAutofill(fieldFinder) { response -> safeCallback(response) }
val inlinePool = buildInlinePool(request)
launchActivityForAutofill(fieldFinder, inlinePool) { response -> safeCallback(response) }
} catch (e: Exception) {
Log.e(TAG, "Unexpected error in onFillRequest", e)
// Provide a simple fallback response to prevent white flash
@@ -96,12 +99,13 @@ class AutofillService : AutofillService() {
// Add debug dataset if enabled in settings
val sharedPreferences = getSharedPreferences("AliasVaultPrefs", android.content.Context.MODE_PRIVATE)
val showSearchText = sharedPreferences.getBoolean("autofill_show_search_text", false)
val fallbackPool = buildInlinePool(request)
if (showSearchText) {
responseBuilder.addDataset(createSearchDebugDataset(fieldFinder, appInfo ?: "unknown"))
responseBuilder.addDataset(createSearchDebugDataset(fieldFinder, appInfo ?: "unknown", fallbackPool))
}
// Add failed to retrieve dataset
responseBuilder.addDataset(createFailedToRetrieveDataset(fieldFinder))
responseBuilder.addDataset(createFailedToRetrieveDataset(fieldFinder, fallbackPool))
safeCallback(responseBuilder.build())
} catch (fallbackError: Exception) {
@@ -121,7 +125,11 @@ class AutofillService : AutofillService() {
callback.onSuccess()
}
private fun launchActivityForAutofill(fieldFinder: FieldFinder, callback: (FillResponse?) -> Unit) {
private fun launchActivityForAutofill(
fieldFinder: FieldFinder,
inlinePool: InlinePresentationHelper.SpecPool,
callback: (FillResponse?) -> Unit,
) {
// Get the app/website information from assist structure.
val appInfo = fieldFinder.getAppInfo()
@@ -175,7 +183,7 @@ class AutofillService : AutofillService() {
val showSearchText = sharedPreferences.getBoolean("autofill_show_search_text", false)
val copyTotpOnFill = sharedPreferences.getBoolean("autofill_copy_totp_on_fill", true)
if (showSearchText) {
responseBuilder.addDataset(createSearchDebugDataset(fieldFinder, appInfo ?: "unknown"))
responseBuilder.addDataset(createSearchDebugDataset(fieldFinder, appInfo ?: "unknown", inlinePool))
}
// If there are no results, return "no matches" placeholder option.
@@ -189,6 +197,7 @@ class AutofillService : AutofillService() {
this@AutofillService,
fieldFinder.autofillableFields,
appInfo,
inlinePool.next(),
),
)
} else {
@@ -199,13 +208,14 @@ class AutofillService : AutofillService() {
fields = fieldFinder.autofillableFields,
item = item,
copyTotpOnSelect = copyTotpOnFill && item.hasTotp,
inlineSpec = inlinePool.next(),
)
responseBuilder.addDataset(dataset)
}
// Add "Open app" option at the bottom (when search text is not shown and there are matches)
if (!showSearchText) {
responseBuilder.addDataset(createOpenAppDataset(fieldFinder))
responseBuilder.addDataset(createOpenAppDataset(fieldFinder, inlinePool))
}
}
@@ -217,9 +227,9 @@ class AutofillService : AutofillService() {
val sharedPreferences = getSharedPreferences("AliasVaultPrefs", android.content.Context.MODE_PRIVATE)
val showSearchText = sharedPreferences.getBoolean("autofill_show_search_text", false)
if (showSearchText) {
responseBuilder.addDataset(createSearchDebugDataset(fieldFinder, appInfo ?: "unknown"))
responseBuilder.addDataset(createSearchDebugDataset(fieldFinder, appInfo ?: "unknown", inlinePool))
}
responseBuilder.addDataset(createFailedToRetrieveDataset(fieldFinder))
responseBuilder.addDataset(createFailedToRetrieveDataset(fieldFinder, inlinePool))
callback(responseBuilder.build())
}
}
@@ -231,9 +241,9 @@ class AutofillService : AutofillService() {
val sharedPreferences = getSharedPreferences("AliasVaultPrefs", android.content.Context.MODE_PRIVATE)
val showSearchText = sharedPreferences.getBoolean("autofill_show_search_text", false)
if (showSearchText) {
responseBuilder.addDataset(createSearchDebugDataset(fieldFinder, appInfo ?: "unknown"))
responseBuilder.addDataset(createSearchDebugDataset(fieldFinder, appInfo ?: "unknown", inlinePool))
}
responseBuilder.addDataset(createFailedToRetrieveDataset(fieldFinder))
responseBuilder.addDataset(createFailedToRetrieveDataset(fieldFinder, inlinePool))
callback(responseBuilder.build())
}
})
@@ -244,13 +254,17 @@ class AutofillService : AutofillService() {
}
Log.d(TAG, "Vault is locked, requiring authentication before fill")
callback(buildLockedFillResponse(fieldFinder, appInfo))
callback(buildLockedFillResponse(fieldFinder, appInfo, inlinePool))
}
/**
* Build a [FillResponse] whose only entry is a "Vault locked" row.
*/
private fun buildLockedFillResponse(fieldFinder: FieldFinder, appInfo: String?): FillResponse {
private fun buildLockedFillResponse(
fieldFinder: FieldFinder,
appInfo: String?,
inlinePool: InlinePresentationHelper.SpecPool,
): FillResponse {
val autofillIds = fieldFinder.autofillableFields.map { it.first }.toTypedArray()
val fieldTypeOrdinals = IntArray(fieldFinder.autofillableFields.size) { i ->
fieldFinder.autofillableFields[i].second.ordinal
@@ -268,28 +282,59 @@ class AutofillService : AutofillService() {
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT,
)
val lockedLabel = getString(R.string.autofill_vault_locked)
val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo)
presentation.setTextViewText(R.id.text, getString(R.string.autofill_vault_locked))
presentation.setTextViewText(R.id.text, lockedLabel)
return FillResponse.Builder()
val responseBuilder = FillResponse.Builder()
val inlineSpec = inlinePool.next()
if (inlineSpec != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val inline = InlinePresentationHelper.buildActionPresentation(this, inlineSpec, lockedLabel)
if (inline != null) {
val presentations = android.service.autofill.Presentations.Builder()
.setMenuPresentation(presentation)
.setInlinePresentation(inline)
.build()
responseBuilder.setAuthentication(autofillIds, pendingIntent.intentSender, presentations)
return responseBuilder.build()
}
}
return responseBuilder
.setAuthentication(autofillIds, pendingIntent.intentSender, presentation)
.build()
}
/**
* Creates inline suggestion pool, showing password suggestions in supported keyboards.
*/
private fun buildInlinePool(request: FillRequest): InlinePresentationHelper.SpecPool {
val inlineRequest = request.inlineSuggestionsRequest
?: return InlinePresentationHelper.SpecPool(emptyList(), 0)
return InlinePresentationHelper.SpecPool(
specs = inlineRequest.inlinePresentationSpecs,
maxCount = inlineRequest.maxSuggestionCount,
)
}
/**
* Create a dataset for the "failed to retrieve" option.
* @param fieldFinder The field finder
* @param inlinePool Keyboard chip slots. If a slot is still free, this row
* also shows as an inline suggestion in the IME; otherwise it appears
* in the dropdown only.
* @return The dataset
*/
private fun createFailedToRetrieveDataset(fieldFinder: FieldFinder): Dataset {
private fun createFailedToRetrieveDataset(
fieldFinder: FieldFinder,
inlinePool: InlinePresentationHelper.SpecPool,
): Dataset {
// Create presentation for the "failed to retrieve" option
val label = getString(R.string.autofill_failed_to_retrieve)
val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo)
presentation.setTextViewText(
R.id.text,
getString(R.string.autofill_failed_to_retrieve),
)
presentation.setTextViewText(R.id.text, label)
val dataSetBuilder = Dataset.Builder(presentation)
addInlineIfAvailable(dataSetBuilder, inlinePool, label)
// Create deep link URL
val deepLinkUrl = "aliasvault://reinitialize"
@@ -321,9 +366,16 @@ class AutofillService : AutofillService() {
* Create a debug dataset showing what string we're searching for, clickable to open the app.
* @param fieldFinder The field finder
* @param searchText The text being searched for
* @param inlinePool Keyboard chip slots. If a slot is still free, this row
* also shows as an inline suggestion in the IME; otherwise it appears
* in the dropdown only.
* @return The dataset
*/
private fun createSearchDebugDataset(fieldFinder: FieldFinder, searchText: String): Dataset {
private fun createSearchDebugDataset(
fieldFinder: FieldFinder,
searchText: String,
inlinePool: InlinePresentationHelper.SpecPool,
): Dataset {
// Create presentation for the debug option (with search icon)
val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_icon)
@@ -335,6 +387,7 @@ class AutofillService : AutofillService() {
presentation.setImageViewResource(R.id.icon, R.drawable.ic_search)
val dataSetBuilder = Dataset.Builder(presentation)
addInlineIfAvailable(dataSetBuilder, inlinePool, searchText)
// Get the app/website information to use as item URL
val appInfo = fieldFinder.getAppInfo()
@@ -369,17 +422,22 @@ class AutofillService : AutofillService() {
/**
* Create a dataset for the "open app" option.
* @param fieldFinder The field finder
* @param inlinePool Keyboard chip slots. If a slot is still free, this row
* also shows as an inline suggestion in the IME; otherwise it appears
* in the dropdown only.
* @return The dataset
*/
private fun createOpenAppDataset(fieldFinder: FieldFinder): Dataset {
private fun createOpenAppDataset(
fieldFinder: FieldFinder,
inlinePool: InlinePresentationHelper.SpecPool,
): Dataset {
// Create presentation for the "open app" option with AliasVault logo
val label = getString(R.string.autofill_open_app)
val presentation = RemoteViews(packageName, R.layout.autofill_dataset_item_logo)
presentation.setTextViewText(
R.id.text,
getString(R.string.autofill_open_app),
)
presentation.setTextViewText(R.id.text, label)
val dataSetBuilder = Dataset.Builder(presentation)
addInlineIfAvailable(dataSetBuilder, inlinePool, label)
// Open the action picker so the user can choose between linking this app
// to an existing credential or creating a new one.
@@ -409,4 +467,18 @@ class AutofillService : AutofillService() {
return dataSetBuilder.build()
}
/**
* Also render this row as an inline keyboard chip if the IME has a free
* slot.
*/
private fun addInlineIfAvailable(
dataSetBuilder: Dataset.Builder,
inlinePool: InlinePresentationHelper.SpecPool,
label: String,
) {
val spec = inlinePool.next() ?: return
val inline = InlinePresentationHelper.buildActionPresentation(this, spec, label) ?: return
dataSetBuilder.setInlinePresentation(inline)
}
}

View File

@@ -8,6 +8,7 @@ import android.util.Log
import android.view.autofill.AutofillId
import android.view.autofill.AutofillValue
import android.widget.RemoteViews
import android.widget.inline.InlinePresentationSpec
import net.aliasvault.app.R
import net.aliasvault.app.autofill.AutofillFillActivity
import net.aliasvault.app.autofill.models.FieldType
@@ -33,6 +34,7 @@ object AutofillDatasetBuilder {
fields: List<Pair<AutofillId, FieldType>>,
item: Item,
copyTotpOnSelect: Boolean,
inlineSpec: InlinePresentationSpec? = null,
): Dataset {
val presentation = RemoteViews(context.packageName, R.layout.autofill_dataset_item_icon)
val builder = Dataset.Builder(presentation)
@@ -64,6 +66,28 @@ object AutofillDatasetBuilder {
presentation.setImageViewBitmap(R.id.icon, bitmap)
}
if (inlineSpec != null) {
val itemDeepLink = "aliasvault://items/${item.id.toString().uppercase()}"
val attribIntent = InlinePresentationHelper.attributionPendingIntent(
context = context,
deepLinkUri = itemDeepLink,
requestCode = item.id.hashCode(),
)
val inline = InlinePresentationHelper.buildCredentialPresentation(
context = context,
spec = inlineSpec,
content = InlinePresentationHelper.CredentialContent(
title = item.name.orEmpty(),
subtitle = applyResult.labelSuffix,
icon = bitmap,
),
attributionIntent = attribIntent,
)
if (inline != null) {
builder.setInlinePresentation(inline)
}
}
val autofillIds = fields.map { it.first }.toTypedArray()
val fieldTypeOrdinals = IntArray(fields.size) { i -> fields[i].second.ordinal }
val authIntent = Intent(context, AutofillFillActivity::class.java).apply {
@@ -92,15 +116,21 @@ object AutofillDatasetBuilder {
context: Context,
fields: List<Pair<AutofillId, FieldType>>,
appInfo: String?,
inlineSpec: InlinePresentationSpec? = null,
): Dataset {
val label = context.getString(R.string.autofill_no_match_found)
val presentation = RemoteViews(context.packageName, R.layout.autofill_dataset_item_logo)
presentation.setTextViewText(
R.id.text,
context.getString(R.string.autofill_no_match_found),
)
presentation.setTextViewText(R.id.text, label)
val dataSetBuilder = Dataset.Builder(presentation)
if (inlineSpec != null) {
val inline = InlinePresentationHelper.buildActionPresentation(context, inlineSpec, label)
if (inline != null) {
dataSetBuilder.setInlinePresentation(inline)
}
}
val encodedUrl = appInfo?.let { URLEncoder.encode(it, "UTF-8") } ?: ""
val deepLinkUrl = "aliasvault://items/autofill-open-app?itemUrl=$encodedUrl"

View File

@@ -0,0 +1,169 @@
package net.aliasvault.app.autofill.utils
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BlendMode
import android.graphics.drawable.Icon
import android.service.autofill.InlinePresentation
import android.util.Log
import android.widget.inline.InlinePresentationSpec
import androidx.autofill.inline.UiVersions
import androidx.autofill.inline.v1.InlineSuggestionUi
import net.aliasvault.app.MainActivity
import net.aliasvault.app.R
/**
* Builds [InlinePresentation] objects for keyboard inline autofill suggestions.
* The IME renders these alongside its own toolbar so users can pick a credential
* without ever opening the autofill dropdown.
*/
@SuppressLint("RestrictedApi")
object InlinePresentationHelper {
private const val TAG = "AliasVaultAutofill"
/**
* Default attribution target — used when a caller does not supply a more
* specific deep link. Long-pressing the inline chip launches MainActivity.
*/
private fun defaultAttributionIntent(context: Context): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
return PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/**
* Build a long-press attribution PendingIntent that opens the given
* deep link (e.g. `aliasvault://items/<id>`). [requestCode] should be
* unique per destination so PendingIntent caching does not collapse
* separate items onto the same intent.
*/
fun attributionPendingIntent(context: Context, deepLinkUri: String, requestCode: Int): PendingIntent {
val intent = Intent(Intent.ACTION_VIEW).apply {
data = android.net.Uri.parse(deepLinkUri)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
return PendingIntent.getActivity(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
/**
* Returns true if the IME-provided [spec] supports the v1 inline UI
* surface we know how to build for.
*/
fun supportsV1(spec: InlinePresentationSpec): Boolean {
return try {
UiVersions.getVersions(spec.style).contains(UiVersions.INLINE_UI_VERSION_1)
} catch (e: Exception) {
Log.w(TAG, "Failed to read inline spec versions", e)
false
}
}
/**
* Visual payload for a credential inline chip.
*
* @property title Primary line (typically the credential name).
* @property subtitle Secondary line (typically username or email); omitted if null/empty.
* @property icon Start-chip icon (typically the site logo); omitted if null.
*/
data class CredentialContent(val title: String, val subtitle: String?, val icon: Bitmap?)
/**
* Build an inline presentation for a credential row. The icon, when
* provided, is shown as the start chip.
*/
fun buildCredentialPresentation(
context: Context,
spec: InlinePresentationSpec,
content: CredentialContent,
attributionIntent: PendingIntent? = null,
): InlinePresentation? {
if (!supportsV1(spec)) {
return null
}
return try {
val contentBuilder = InlineSuggestionUi
.newContentBuilder(attributionIntent ?: defaultAttributionIntent(context))
.setTitle(content.title)
.setContentDescription(content.title)
if (!content.subtitle.isNullOrEmpty()) {
contentBuilder.setSubtitle(content.subtitle)
}
if (content.icon != null) {
val icon = Icon.createWithBitmap(content.icon)
icon.setTintBlendMode(BlendMode.DST)
contentBuilder.setStartIcon(icon)
}
InlinePresentation(contentBuilder.build().slice, spec, false)
} catch (e: Exception) {
Log.w(TAG, "Failed to build inline credential presentation", e)
null
}
}
/**
* Hands out inline-presentation specs in dataset-add order, honouring the
* IME's maxSuggestionCount cap. Returns null once the budget is spent so
* callers can skip the inline call without changing the dropdown path.
*/
class SpecPool(private val specs: List<InlinePresentationSpec>, maxCount: Int) {
private val budget: Int = minOf(maxCount, MAX_INLINE_BUDGET).coerceAtLeast(0)
private var consumed: Int = 0
/**
* Returns the next spec for an inline suggestion, or null if the
* caller should fall back to a dropdown-only dataset.
*/
fun next(): InlinePresentationSpec? {
if (specs.isEmpty() || consumed >= budget) {
return null
}
val spec = specs.getOrElse(consumed) { specs.last() }
consumed++
return spec
}
companion object {
private const val MAX_INLINE_BUDGET = 20
}
}
/**
* Build an inline presentation for an action chip (open app, no match,
* vault locked, etc). Uses the AliasVault launcher icon.
*/
fun buildActionPresentation(
context: Context,
spec: InlinePresentationSpec,
title: String,
): InlinePresentation? {
if (!supportsV1(spec)) {
return null
}
return try {
val icon = Icon.createWithResource(context, R.drawable.av_logo)
val content = InlineSuggestionUi.newContentBuilder(defaultAttributionIntent(context))
.setTitle(title)
.setContentDescription(title)
.setStartIcon(icon)
.build()
InlinePresentation(content.slice, spec, false)
} catch (e: Exception) {
Log.w(TAG, "Failed to build inline action presentation", e)
null
}
}
}