androidengineers.Book a session

Android Platform Services

Exercise: Rich Notification + Widget

exercise55 minHard

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

  • MessagingStyle notification 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

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Exercise: Rich Notification + Widget | Android System Design | Android Engineers