34ba9216975c
feat: keyboard-row chip polish — entry monograms and an always-reachable picker chip (v2.14.0)
- time
- 2026-08-04 22:05 UTC · 2 months ago
- machine
- koh2
- parent
- 5d31fe2bb493
↓ peach-android-34ba9216.tar.gzatlas at this save
files changed5 files
VERSION+1 −1app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+15 −5app/src/main/java/com/peachpasswords/autofill/ChipMonogramIcon.kt+41 −0app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+14 −5app/src/test/java/com/peachpasswords/autofill/AutofillChipBudgetTest.kt+19 −0app/src/main/java/com/peachpasswords/autofill/AutofillResponseBuilder.kt+15 −5
@@ -75,8 +75,12 @@7575 .mapNotNull(::extractDomain)7676 .any { it == originDomain }7777 78- /** What a suggestion row or keyboard chip shows about an entry. */79- data class PreviewLabels(val title: String, val subtitle: String)78+ /**79+ * What a suggestion row or keyboard chip shows about an entry.80+ * [iconSource] seeds the chip monogram; null keeps the Peach mark81+ * (always the case in privacy mode — initials would leak titles too).82+ */83+ data class PreviewLabels(val title: String, val subtitle: String, val iconSource: String?)8084 8185 /**8286 * Privacy previews: with [privatePreview] the rows and chips render only@@ -85,9 +89,13 @@8589 */8690 fun previewLabels(privatePreview: Boolean, entry: VaultEntry, originDomain: String?): PreviewLabels =8791 if (privatePreview) {88- PreviewLabels("Peach", originDomain ?: "")92+ PreviewLabels("Peach", originDomain ?: "", null)8993 } else {90- PreviewLabels(entry.title, entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "")94+ PreviewLabels(95+ entry.title,96+ entry.username ?: entry.email ?: entry.url?.let(::extractDomain) ?: "",97+ entry.title,98+ )9199 }92100 93101 fun rowFor(context: Context, labels: PreviewLabels): RemoteViews =@@ -251,10 +259,12 @@251259 null252260 } else {253261 val pending = AutofillIntents.noopChipIntent(context)262+ val startIcon = labels.iconSource?.let { ChipMonogramIcon.icon(it) }263+ ?: android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark)254264 val slice = InlineSuggestionUi.newContentBuilder(pending)255265 .setTitle(labels.title)256266 .apply { if (labels.subtitle.isNotBlank()) setSubtitle(labels.subtitle) }257- .setStartIcon(android.graphics.drawable.Icon.createWithResource(context, R.drawable.ic_peach_mark))267+ .setStartIcon(startIcon)258268 .build()259269 .slice260270 InlinePresentation(slice, spec, false)app/src/main/java/com/peachpasswords/autofill/ChipMonogramIcon.kt+41 −0
@@ -0,0 +1,41 @@1+package com.peachpasswords.autofill2+3+import android.graphics.Bitmap4+import android.graphics.Canvas5+import android.graphics.Paint6+import android.graphics.Typeface7+import android.graphics.drawable.Icon8+import android.util.LruCache9+import androidx.compose.ui.graphics.toArgb10+import com.peachpasswords.ui.components.titleBackground11+import com.peachpasswords.ui.components.titleInitials12+13+/**14+ * Monogram chip icons for inline autofill suggestions — the same initials and15+ * deterministic background as the vault list (titleInitials/titleBackground),16+ * rendered once per source and cached. The IME renders these outside any17+ * Peach window, so they must be plain bitmaps.18+ */19+object ChipMonogramIcon {20+ private const val SIZE_PX = 6421+ private val cache = LruCache<String, Icon>(32)22+23+ fun icon(source: String): Icon {24+ cache.get(source)?.let { return it }25+ val bitmap = Bitmap.createBitmap(SIZE_PX, SIZE_PX, Bitmap.Config.ARGB_8888)26+ val canvas = Canvas(bitmap)27+ val background = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = titleBackground(source).toArgb() }28+ val radius = SIZE_PX * 0.23f29+ canvas.drawRoundRect(0f, 0f, SIZE_PX.toFloat(), SIZE_PX.toFloat(), radius, radius, background)30+ val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {31+ // T0 ink at ~92%, matching the vault-list initials chip.32+ color = 0xEBF0E8D8.toInt()33+ textSize = SIZE_PX * 0.34f34+ typeface = Typeface.MONOSPACE35+ textAlign = Paint.Align.CENTER36+ }37+ val centerY = SIZE_PX / 2f - (text.descent() + text.ascent()) / 2f38+ canvas.drawText(titleInitials(source), SIZE_PX / 2f, centerY, text)39+ return Icon.createWithBitmap(bitmap).also { cache.put(source, it) }40+ }41+}app/src/main/java/com/peachpasswords/autofill/PeachAutofillService.kt+14 −5
@@ -51,13 +51,19 @@5151 val response = FillResponse.Builder()5252 var datasetCount = 05353 var hasLoginMatch = false54+ // Reserve the last inline slot for the branded picker chip so the full55+ // vault stays reachable from the keyboard row even when entry chips56+ // fill every other slot.57+ val pickerSpec = inlineSpecs.lastOrNull()58+ val entryChipBudget = inlineChipBudget(inlineSpecs.size)59+ var entryChips = 05460 5561 if (vault.isUnlocked.value) {5662 val matches = AutofillResponseBuilder5763 .matchEntries(vault.getEntries(), parsed)5864 .all.take(MAX_DATASETS)59- matches.forEachIndexed { index, entry ->60- val spec = inlineSpecs.getOrNull(index)65+ matches.forEach { entry ->66+ val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null6167 AutofillResponseBuilder.datasetFor(context, parsed, entry, spec, privatePreview)?.let {6268 response.addDataset(it)6369 datasetCount++@@ -67,8 +73,8 @@6773 }6874 6975 if (parsed.hasCardField && vault.isUnlocked.value) {70- vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEachIndexed { index, entry ->71- val spec = inlineSpecs.getOrNull(datasetCount + index)76+ vault.getEntries().filter { it.isCard() }.take(MAX_DATASETS - datasetCount).forEach { entry ->77+ val spec = if (entryChips < entryChipBudget) inlineSpecs[entryChips++] else null7278 AutofillResponseBuilder.cardDatasetFor(context, parsed, entry, spec, privatePreview)?.let {7379 response.addDataset(it)7480 datasetCount++@@ -97,7 +103,7 @@97103 datasetCount = datasetCount,98104 )99105 runCatching {100- response.addDataset(pickerDataset(context, parsed, pickerLabel, inlineSpecs.getOrNull(datasetCount)))106+ response.addDataset(pickerDataset(context, parsed, pickerLabel, pickerSpec))101107 datasetCount++102108 }103109 @@ -198,6 +204,9 @@198204 const val MAX_DATASETS = 6199205 }200206 }207+208+/** Entry/card chips get every inline slot except the one reserved for the picker. */209+internal fun inlineChipBudget(totalSpecs: Int): Int = if (totalSpecs > 0) totalSpecs - 1 else 0201210 202211 internal fun autofillPickerLabel(unlocked: Boolean, datasetCount: Int): String = when {203212 !unlocked -> "Unlock Peach"app/src/test/java/com/peachpasswords/autofill/AutofillChipBudgetTest.kt+19 −0
@@ -0,0 +1,19 @@1+package com.peachpasswords.autofill2+3+import org.junit.Assert.assertEquals4+import org.junit.Test5+6+class AutofillChipBudgetTest {7+ @Test fun no_specs_means_no_entry_chips() {8+ assertEquals(0, inlineChipBudget(0))9+ }10+11+ @Test fun one_spec_is_fully_reserved_for_the_picker() {12+ assertEquals(0, inlineChipBudget(1))13+ }14+15+ @Test fun picker_always_keeps_the_last_slot() {16+ assertEquals(1, inlineChipBudget(2))17+ assertEquals(2, inlineChipBudget(3))18+ }19+}