fix(auto): project messaging notifications to Android Auto

Gearhead's MsgNotifParser was rejecting Meshtastic notifications with:
- 'No semantic reply action found'
- 'No semantic mark-as-read action found'
- 'added an invalid shortcut'

Fixes:
- Tag reply action with SEMANTIC_ACTION_REPLY + setShowsUserInterface(false)
  + setAllowGeneratedReplies(true) so Gearhead/Assistant can surface it.
- Tag mark-as-read action with SEMANTIC_ACTION_MARK_AS_READ.
- Publish an on-demand long-lived conversation shortcut whose id matches
  the notification's setShortcutId(contactKey). Previously only favorites
  + channels at index 0 had shortcuts, so DMs received on a non-zero
  channel referenced an unpublished shortcut and Android Auto refused to
  project them.

Verified on Pixel 6a + DHU 2.0: notifications now carry matching
long-lived shortcuts and project as messaging HUNs with reply, mark-read
and reaction actions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich 2026-04-17 08:48:38 -05:00
parent 36f770fd0b
commit 07772917c3
2 changed files with 68 additions and 1 deletions

View file

@ -158,6 +158,34 @@ class ConversationShortcutManager(
setPackage(context.packageName)
}
/**
* Ensures a long-lived conversation shortcut exists for [contactKey]. Called on demand when a notification is about
* to reference a shortcut id that may not have been pre-published (e.g., an incoming DM on a non-primary channel,
* or from a non-favorite node). Android Auto requires a matching published shortcut to project the notification as
* a messaging HUN.
*/
fun ensureConversationShortcut(contactKey: String, person: Person, label: String) {
val alreadyPublished = ShortcutManagerCompat.getDynamicShortcuts(context).any { it.id == contactKey }
if (alreadyPublished) return
val shortcut =
ShortcutInfoCompat.Builder(context, contactKey)
.setShortLabel(label)
.setLongLabel(label)
.setLocusId(LocusIdCompat(contactKey))
.setPerson(person)
.setLongLived(true)
.setCategories(setOf(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION))
.setIntent(conversationIntent(contactKey))
.build()
try {
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
} catch (e: IllegalArgumentException) {
Logger.e(e) { "Failed to publish on-demand shortcut $contactKey" }
} catch (e: IllegalStateException) {
Logger.e(e) { "Failed to publish on-demand shortcut $contactKey" }
}
}
private fun createPersonIcon(name: String, backgroundColor: Int, foregroundColor: Int): IconCompat {
val size = ICON_SIZE
val bitmap = createBitmap(size, size)

View file

@ -110,6 +110,7 @@ class MeshServiceNotificationsImpl(
private val context: Context,
private val packetRepository: Lazy<PacketRepository>,
private val nodeRepository: Lazy<NodeRepository>,
private val shortcutManager: Lazy<ConversationShortcutManager>,
) : MeshServiceNotifications {
private val notificationManager = context.getSystemService<NotificationManager>()!!
@ -618,6 +619,8 @@ class MeshServiceNotificationsImpl(
}
val lastMessage = history.last()
ensureShortcutForNotification(contactKey, isBroadcast, channelName, lastMessage)
builder
.setCategory(Notification.CATEGORY_MESSAGE)
.setAutoCancel(true)
@ -773,6 +776,36 @@ class MeshServiceNotificationsImpl(
}
}
private fun ensureShortcutForNotification(
contactKey: String,
isBroadcast: Boolean,
channelName: String?,
lastMessage: Message,
) {
val person =
if (isBroadcast) {
Person.Builder().setName(channelName ?: contactKey).setKey(contactKey).build()
} else {
Person.Builder()
.setName(lastMessage.node.user.long_name)
.setKey(lastMessage.node.user.id)
.setIcon(
createPersonIcon(
lastMessage.node.user.short_name,
lastMessage.node.colors.second,
lastMessage.node.colors.first,
),
)
.build()
}
val label =
when {
isBroadcast -> channelName ?: contactKey
else -> lastMessage.node.user.long_name.ifEmpty { lastMessage.node.user.short_name }
}
shortcutManager.value.ensureConversationShortcut(contactKey, person, label)
}
private fun createReplyAction(contactKey: String): NotificationCompat.Action {
val replyLabel = getString(Res.string.reply)
val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyLabel).build()
@ -792,6 +825,9 @@ class MeshServiceNotificationsImpl(
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, replyLabel, replyPendingIntent)
.addRemoteInput(remoteInput)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.setShowsUserInterface(false)
.setAllowGeneratedReplies(true)
.build()
}
@ -810,7 +846,10 @@ class MeshServiceNotificationsImpl(
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, label, pendingIntent).build()
return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_view, label, pendingIntent)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.setShowsUserInterface(false)
.build()
}
private fun createReactionAction(