Build a messaging notification with MessagingStyle and inline reply, and a companion Glance home screen widget that shows the latest unread count and lets users mark all as read with one tap.
Goal
MessagingStylenotification with inline reply action- Replying from the notification updates Room and updates the notification
- Glance widget shows unread count; taps open the inbox
- "Mark all read" in widget updates Room; widget refreshes
Step 1: Notification Builder with MessagingStyle
class MessagingNotificationHelper @Inject constructor(
private val context: Context,
private val conversationRepository: ConversationRepository
) {
suspend fun showOrUpdateNotification(conversationId: String) {
val conversation = conversationRepository.getConversation(conversationId)
val recentMessages = conversationRepository.getRecentMessages(conversationId, limit = 5)
val mySelf = Person.Builder().setName("You").build()
val sender = Person.Builder()
.setName(conversation.partnerName)
.setIcon(IconCompat.createWithBitmap(loadAvatar(conversation.partnerAvatarUrl)))
.build()
val style = NotificationCompat.MessagingStyle(mySelf).apply {
conversationTitle = null // null = 1:1 conversation
recentMessages.forEach { msg ->
addMessage(msg.text, msg.sentAt, if (msg.isOutgoing) null else sender)
}
}
val notification = NotificationCompat.Builder(context, "messages")
.setSmallIcon(R.drawable.ic_message)
.setStyle(style)
.setShortcutId("conv_$conversationId")
.addAction(buildInlineReplyAction(conversationId))
.setAutoCancel(false) // keep visible until conversation opened
.build()
NotificationManagerCompat.from(context).notify(conversationId.hashCode(), notification)
}
private fun buildInlineReplyAction(conversationId: String): NotificationCompat.Action {
val remoteInput = RemoteInput.Builder("reply_text").setLabel("Reply…").build()
val intent = PendingIntent.getBroadcast(
context,
conversationId.hashCode(),
Intent(context, NotificationReplyReceiver::class.java).putExtra("conversation_id", conversationId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
return NotificationCompat.Action.Builder(R.drawable.ic_reply, "Reply", intent)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.build()
}
private suspend fun loadAvatar(url: String): Bitmap {
return Glide.with(context).asBitmap().load(url).submit(64, 64).get()
}
}
Step 2: Reply Receiver
class NotificationReplyReceiver : BroadcastReceiver() {
@Inject lateinit var conversationRepository: ConversationRepository
@Inject lateinit var notificationHelper: MessagingNotificationHelper
override fun onReceive(context: Context, intent: Intent) {
val conversationId = intent.getStringExtra("conversation_id") ?: return
val reply = RemoteInput.getResultsFromIntent(intent)
?.getCharSequence("reply_text")?.toString() ?: return
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
// Save reply to Room
conversationRepository.sendMessage(conversationId, reply)
// Update the notification to show reply was sent
notificationHelper.showOrUpdateNotification(conversationId)
} finally {
pendingResult.finish()
}
}
}
}
Step 3: Glance Widget
class InboxWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val unreadCount = currentState<Int>()
InboxWidgetContent(unreadCount, context)
}
}
}
@Composable
fun InboxWidgetContent(unreadCount: Int, context: Context) {
GlanceTheme {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.background)
.padding(16.dp)
.appWidgetBackground()
.cornerRadius(16.dp)
.clickable(actionStartActivity<MainActivity>(
actionParametersOf(ActionParameters.Key<String>("destination") to "inbox")
))
) {
Text(
"Inbox",
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
color = GlanceTheme.colors.onBackground
)
)
Spacer(GlanceModifier.height(8.dp))
if (unreadCount > 0) {
Text(
"$unreadCount unread",
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.primary
)
)
Spacer(GlanceModifier.height(12.dp))
Button(
text = "Mark all read",
onClick = actionRunCallback<MarkAllReadCallback>()
)
} else {
Text(
"All caught up!",
style = TextStyle(fontSize = 16.sp, color = GlanceTheme.colors.onBackground)
)
}
}
}
}
class MarkAllReadCallback : ActionCallback {
@Inject lateinit var conversationRepository: ConversationRepository
override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) {
conversationRepository.markAllRead()
updateAllWidgets(context)
}
private suspend fun updateAllWidgets(context: Context) {
val manager = GlanceAppWidgetManager(context)
val ids = manager.getGlanceIds(InboxWidget::class.java)
val newUnreadCount = conversationRepository.getUnreadCount()
ids.forEach { id ->
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
prefs.toMutablePreferences().apply { this[intPreferencesKey("unread_count")] = newUnreadCount }
}
InboxWidget().update(context, id)
}
}
}
Step 4: Widget Receiver
class InboxWidgetReceiver : GlanceAppWidgetReceiver() {
override val glanceAppWidget = InboxWidget()
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
enqueueWidgetRefreshWork(context)
}
}
// Refresh worker — runs when Room signals unread count changes
class WidgetRefreshWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val conversationRepository: ConversationRepository
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val unreadCount = conversationRepository.getUnreadCount()
val manager = GlanceAppWidgetManager(applicationContext)
val ids = manager.getGlanceIds(InboxWidget::class.java)
ids.forEach { id ->
updateAppWidgetState(applicationContext, PreferencesGlanceStateDefinition, id) { prefs ->
prefs.toMutablePreferences().apply {
this[intPreferencesKey("unread_count")] = unreadCount
}
}
InboxWidget().update(applicationContext, id)
}
return Result.success()
}
}
Verification Checklist
[ ] Receive a new message → MessagingStyle notification appears
[ ] Reply inline from notification → message saved; notification updates
[ ] Open conversation → notification dismissed (set AutoCancel on read)
[ ] Add widget to home screen → shows correct unread count
[ ] Receive new message → widget updates within 60s (or on next WorkManager run)
[ ] Tap "Mark all read" → unread count becomes 0; notification cleared
[ ] Tap widget body → opens inbox screen via deep link