diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt index 3c388d43a9..0268b13de2 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt @@ -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() } diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt index 6faf14418a..8ed486ffda 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/MeshNotificationManagerImpl.kt @@ -188,10 +188,10 @@ class MeshNotificationManagerImpl( */ private val personIconCache = ConcurrentHashMap() - /** 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, diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt index 5ba4c42dc9..54cae4ecd4 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/PersonIconFactory.kt @@ -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) }