mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-03 00:54:34 -04:00
fix(notifications): give bubbles an adaptive icon (#7336)
This commit is contained in:
1 parent
9f5d5c6a21
commit
6d8622b094
3 files changed
+52
-16
No files matched your search
+17
@@ -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() }
|
||||
|
||||
+5
-5
@@ -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,
|
||||
|
||||
+30
-11
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user