fix(notifications): give bubbles an adaptive icon (#7336)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-24 13:56:38 +00:00
1 parent 9f5d5c6a21
commit 6d8622b094
3 files changed
+52 -16

No files matched your search

@@ -203,6 +203,11 @@ class MeshNotificationManagerImplConversationTest {
val bubble = posted.bubbleMetadata
assertNotNull(bubble, "conversation notifications must offer a bubble")
assertNotNull(bubble.icon, "a bubble without an icon is rejected")
assertEquals(
android.graphics.drawable.Icon.TYPE_ADAPTIVE_BITMAP,
bubble.icon?.type,
"Android 10 rejects a plain bitmap bubble icon",
)
assertEquals("0^all", posted.shortcutId, "the bubble needs its long-lived conversation shortcut")
assertTrue(
posted.extras.containsKey(Notification.EXTRA_PEOPLE_LIST),
@@ -233,6 +238,18 @@ class MeshNotificationManagerImplConversationTest {
)
}
@Test
@Config(sdk = [29])
fun `conversation notifications post on Android 10`() = runWithRenderScope { scope ->
val manager = createManager(scope).also { it.initChannels() }
mockHistory(message("hello", read = false, receivedTime = 1_000))
manager.updateMessageNotification("0^all", "Hawk Ridge", "hello", isBroadcast = true, channelName = "LongFast")
advanceUntilIdle()
assertNotNull(activeByTag("message").single().notification.bubbleMetadata)
}
@Test
fun `read messages become historic context and unread messages stay alerting`() = runWithRenderScope { scope ->
val manager = createManager(scope).also { it.initChannels() }
@@ -188,10 +188,10 @@ class MeshNotificationManagerImpl(
*/
private val personIconCache = ConcurrentHashMap<String, IconCompat>()
/** Rounded variant, for surfaces that mask the icon into a circle (notification bubbles). */
private fun cachedRoundedPersonIcon(key: String, shortName: String, backgroundColor: Int, foregroundColor: Int) =
personIconCache.getOrPut("rounded|$key|$shortName|$backgroundColor|$foregroundColor") {
PersonIconFactory.createLabel(shortName, backgroundColor, foregroundColor, rounded = true)
/** Adaptive variant, for notification bubbles, which the system masks to its own shape. */
private fun cachedBubblePersonIcon(key: String, shortName: String, backgroundColor: Int, foregroundColor: Int) =
personIconCache.getOrPut("adaptive|$key|$shortName|$backgroundColor|$foregroundColor") {
PersonIconFactory.createAdaptive(shortName, backgroundColor, foregroundColor)
}
/** Circular, node-colored avatar holding the sender's full short name (e.g. "2c3d"), not just its first letter. */
@@ -849,7 +849,7 @@ class MeshNotificationManagerImpl(
// The bubble wears the other party's avatar, not ours — a bubble is recognised by who is in it.
val bubbleNode = lastMessage.node
val bubbleIcon =
cachedRoundedPersonIcon(
cachedBubblePersonIcon(
bubbleNode.user.id,
bubbleNode.user.short_name,
bubbleNode.colors.second,
@@ -33,6 +33,7 @@ import androidx.core.graphics.drawable.IconCompat
internal object PersonIconFactory {
private const val ICON_SIZE = 128
private const val ADAPTIVE_ICON_SIZE = ICON_SIZE * 108 / 72
private const val TEXT_SIZE_RATIO = 0.5f
// Leave a margin so multi-character labels (e.g. a 4-char node short name) don't touch the edge.
@@ -58,9 +59,37 @@ internal object PersonIconFactory {
fun createLabel(label: String, backgroundColor: Int, foregroundColor: Int, rounded: Boolean): IconCompat =
render(label.ifBlank { "?" }, backgroundColor, foregroundColor, rounded)
/**
* Full-bleed adaptive avatar showing [label], for notification bubbles: Android 10 rejects a plain bitmap bubble
* icon, and the system applies its own mask shape.
*/
fun createAdaptive(label: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
val bitmap = createBitmap(ADAPTIVE_ICON_SIZE, ADAPTIVE_ICON_SIZE)
val canvas = Canvas(bitmap)
canvas.drawColor(backgroundColor)
// The system shows only the central 72/108 of an adaptive bitmap, so the label is fitted to that safe zone.
drawLabel(canvas, label.ifBlank { "?" }, foregroundColor, contentSize = ICON_SIZE.toFloat())
return IconCompat.createWithAdaptiveBitmap(bitmap)
}
private fun firstInitial(name: String): String =
if (name.isEmpty()) "?" else String(Character.toChars(name.codePointAt(0))).uppercase()
private fun drawLabel(canvas: Canvas, text: String, foregroundColor: Int, contentSize: Float) {
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = foregroundColor
paint.textAlign = Paint.Align.CENTER
paint.textSize = contentSize * TEXT_SIZE_RATIO
// Shrink the text if it would overflow the icon (keeps 4-char short names inside the shape).
val measured = paint.measureText(text)
val maxWidth = contentSize * MAX_TEXT_WIDTH_RATIO
if (measured > maxWidth) paint.textSize *= maxWidth / measured
val xPos = canvas.width / 2f
val yPos = canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f
canvas.drawText(text, xPos, yPos, paint)
}
private fun render(text: String, backgroundColor: Int, foregroundColor: Int, rounded: Boolean): IconCompat {
val bitmap = createBitmap(ICON_SIZE, ICON_SIZE)
val canvas = Canvas(bitmap)
@@ -79,17 +108,7 @@ internal object PersonIconFactory {
canvas.drawRoundRect(0f, top, size, size - top, cap, cap, paint)
}
paint.color = foregroundColor
paint.textAlign = Paint.Align.CENTER
paint.textSize = ICON_SIZE * TEXT_SIZE_RATIO
// Shrink the text if it would overflow the icon (keeps 4-char short names inside the circle).
val measured = paint.measureText(text)
val maxWidth = ICON_SIZE * MAX_TEXT_WIDTH_RATIO
if (measured > maxWidth) paint.textSize *= maxWidth / measured
val xPos = canvas.width / 2f
val yPos = canvas.height / 2f - (paint.descent() + paint.ascent()) / 2f
canvas.drawText(text, xPos, yPos, paint)
drawLabel(canvas, text, foregroundColor, contentSize = size)
return IconCompat.createWithBitmap(bitmap)
}