feat: improve Android push notifications with modern notification APIs

- Use MessagingStyle for DM notifications with sender avatars and
  conversation formatting (integrates with Android Bubbles/conversation space)
- Add Direct Reply action so users can respond to DMs from the
  notification shade without opening the app
- Add Mark as Read action with SEMANTIC_ACTION_MARK_AS_READ for
  Android Auto/Wear OS integration
- Apply notification grouping with InboxStyle summary notifications
  for both DMs and Zaps (group keys were defined but never used)
- Use BigTextStyle for zap notifications so long messages expand
- Set proper notification categories (CATEGORY_MESSAGE for DMs,
  CATEGORY_SOCIAL for zaps) for OS-level prioritization and DND bypass
- Fix PendingIntent flag from FLAG_MUTABLE to FLAG_IMMUTABLE where
  mutability is not needed (security improvement)
- Replace blocking image load (executeBlocking) with suspend-friendly
  Coil execute() on IO dispatcher
- Upgrade DM channel importance to IMPORTANCE_HIGH for heads-up display

https://claude.ai/code/session_012w6K8H4nvZYxeRryW2USAp
This commit is contained in:
Claude
2026-03-17 03:00:08 +00:00
parent da38555035
commit 856e85988a
5 changed files with 390 additions and 115 deletions
+5
View File
@@ -240,6 +240,11 @@
<action android:name="com.shared.NOSTR" />
</intent-filter>
</receiver>
<receiver
android:name=".service.notifications.NotificationReplyReceiver"
android:exported="false" />
</application>
@@ -183,7 +183,7 @@ class EventNotificationConsumer(
}
}
private fun notify(
private suspend fun notify(
event: ChatMessageEncryptedFileHeaderEvent,
account: Account,
) {
@@ -210,13 +210,10 @@ class EventNotificationConsumer(
val content = chatNote.event?.content ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri =
chatNote.toNEvent() + ACCOUNT_QUERY_PARAM +
account.signer.pubKey
.hexToByteArray()
.toNpub()
val accountNpub = account.signer.pubKey.hexToByteArray().toNpub()
val chatroomMembers = chatRoom.users.joinToString(",")
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
// TODO: Show Image on notification
notificationManager()
.sendDMNotification(
event.id,
@@ -226,12 +223,14 @@ class EventNotificationConsumer(
userPicture,
noteUri,
applicationContext,
accountNpub = accountNpub,
chatroomMembers = chatroomMembers,
)
}
}
}
private fun notify(
private suspend fun notify(
event: ChatMessageEvent,
account: Account,
) {
@@ -255,11 +254,10 @@ class EventNotificationConsumer(
val content = chatNote.event?.content ?: ""
val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture()
val noteUri =
chatNote.toNEvent() + ACCOUNT_QUERY_PARAM +
account.signer.pubKey
.hexToByteArray()
.toNpub()
val accountNpub = account.signer.pubKey.hexToByteArray().toNpub()
val chatroomMembers = chatRoom.users.joinToString(",")
val noteUri = chatNote.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
notificationManager()
.sendDMNotification(
event.id,
@@ -269,6 +267,8 @@ class EventNotificationConsumer(
userPicture,
noteUri,
applicationContext,
accountNpub = accountNpub,
chatroomMembers = chatroomMembers,
)
}
}
@@ -297,13 +297,21 @@ class EventNotificationConsumer(
decryptContent(note, account.signer)?.let { content ->
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri =
note.toNEvent() + ACCOUNT_QUERY_PARAM +
account.signer.pubKey
.hexToByteArray()
.toNpub()
val accountNpub = account.signer.pubKey.hexToByteArray().toNpub()
val noteUri = note.toNEvent() + ACCOUNT_QUERY_PARAM + accountNpub
notificationManager()
.sendDMNotification(event.id, content, user, event.createdAt, userPicture, noteUri, applicationContext)
.sendDMNotification(
event.id,
content,
user,
event.createdAt,
userPicture,
noteUri,
applicationContext,
accountNpub = accountNpub,
chatroomMembers = null,
)
}
}
}
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.app.RemoteInput
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
class NotificationReplyReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onReceive(
context: Context,
intent: Intent,
) {
val notificationId = intent.getIntExtra(NotificationUtils.KEY_NOTIFICATION_ID, 0)
val notificationManager =
ContextCompat.getSystemService(context, NotificationManager::class.java)
as NotificationManager
when (intent.action) {
NotificationUtils.MARK_READ_ACTION -> {
notificationManager.cancel(notificationId)
}
NotificationUtils.REPLY_ACTION -> {
val replyText = RemoteInput.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
val chatroomMembersStr = intent.getStringExtra(NotificationUtils.KEY_CHATROOM_MEMBERS) ?: return
val members = chatroomMembersStr.split(",").filter { it.isNotBlank() }
if (members.isEmpty()) return
val pendingResult = goAsync()
scope.launch {
try {
sendReply(accountNpub, members, replyText)
notificationManager.cancel(notificationId)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("NotificationReply", "Failed to send reply: ${e.message}")
} finally {
pendingResult.finish()
}
}
}
}
}
private suspend fun sendReply(
accountNpub: String,
chatroomMembers: List<String>,
replyText: String,
) {
val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage(accountNpub) ?: return
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
val recipients = chatroomMembers.map { PTag(it) }
val template = ChatMessageEvent.build(msg = replyText, to = recipients)
account.sendNip17PrivateMessage(template)
}
}
@@ -25,23 +25,37 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import coil3.ImageLoader
import coil3.asDrawable
import coil3.executeBlocking
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
object NotificationUtils {
private var dmChannel: NotificationChannel? = null
private var zapChannel: NotificationChannel? = null
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
const val KEY_REPLY_TEXT = "key_reply_text"
const val KEY_NOTIFICATION_ID = "key_notification_id"
const val KEY_ACCOUNT_NPUB = "key_account_npub"
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
private const val DM_SUMMARY_ID = 0x10000
private const val ZAP_SUMMARY_ID = 0x20000
fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel {
if (dmChannel != null) return dmChannel!!
@@ -50,13 +64,12 @@ object NotificationUtils {
NotificationChannel(
stringRes(applicationContext, R.string.app_notification_dms_channel_id),
stringRes(applicationContext, R.string.app_notification_dms_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
NotificationManager.IMPORTANCE_HIGH,
).apply {
description =
stringRes(applicationContext, R.string.app_notification_dms_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -78,7 +91,6 @@ object NotificationUtils {
stringRes(applicationContext, R.string.app_notification_zaps_channel_description)
}
// Register the channel with the system
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -87,7 +99,7 @@ object NotificationUtils {
return zapChannel!!
}
fun NotificationManager.sendZapNotification(
suspend fun NotificationManager.sendZapNotification(
id: String,
messageBody: String,
messageTitle: String,
@@ -96,109 +108,98 @@ object NotificationUtils {
uri: String,
applicationContext: Context,
) {
val zapChannel = getOrCreateZapChannel(applicationContext)
getOrCreateZapChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_zaps_channel_id)
sendNotification(
id,
messageBody,
messageTitle,
time,
pictureUrl,
uri,
channelId,
ZAP_GROUP_KEY,
applicationContext,
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
pictureUrl = pictureUrl,
uri = uri,
channelId = channelId,
notificationGroupKey = ZAP_GROUP_KEY,
category = NotificationCompat.CATEGORY_SOCIAL,
summaryId = ZAP_SUMMARY_ID,
summaryText = stringRes(applicationContext, R.string.app_notification_zaps_summary),
applicationContext = applicationContext,
)
}
fun NotificationManager.sendDMNotification(
suspend fun NotificationManager.sendDMNotification(
id: String,
messageBody: String,
messageTitle: String,
senderName: String,
time: Long,
pictureUrl: String?,
uri: String,
applicationContext: Context,
accountNpub: String? = null,
chatroomMembers: String? = null,
) {
val dmChannel = getOrCreateDMChannel(applicationContext)
getOrCreateDMChannel(applicationContext)
val channelId = stringRes(applicationContext, R.string.app_notification_dms_channel_id)
sendNotification(
id,
messageBody,
messageTitle,
time,
pictureUrl,
uri,
channelId,
DM_GROUP_KEY,
applicationContext,
sendDMNotificationStyled(
id = id,
messageBody = messageBody,
senderName = senderName,
time = time,
pictureUrl = pictureUrl,
uri = uri,
channelId = channelId,
applicationContext = applicationContext,
accountNpub = accountNpub,
chatroomMembers = chatroomMembers,
)
}
fun NotificationManager.sendNotification(
private suspend fun loadBitmap(
pictureUrl: String,
applicationContext: Context,
): Bitmap? =
withContext(Dispatchers.IO) {
try {
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
val imageLoader = ImageLoader(applicationContext)
val result = imageLoader.execute(request)
(result.image?.asDrawable(applicationContext.resources) as? BitmapDrawable)?.bitmap
} catch (_: Exception) {
null
}
}
private suspend fun NotificationManager.sendDMNotificationStyled(
id: String,
messageBody: String,
messageTitle: String,
senderName: String,
time: Long,
pictureUrl: String?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context,
) {
if (pictureUrl != null) {
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
val imageLoader = ImageLoader(applicationContext)
val imageResult = imageLoader.executeBlocking(request)
sendNotificationInner(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
picture = imageResult.image?.asDrawable(applicationContext.resources) as? BitmapDrawable,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext,
)
} else {
sendNotificationInner(
id = id,
messageBody = messageBody,
messageTitle = messageTitle,
time = time,
picture = null,
uri = uri,
channelId,
notificationGroupKey,
applicationContext = applicationContext,
)
}
}
private fun NotificationManager.sendNotificationInner(
id: String,
messageBody: String,
messageTitle: String,
time: Long,
picture: BitmapDrawable?,
uri: String,
channelId: String,
notificationGroupKey: String,
applicationContext: Context,
accountNpub: String?,
chatroomMembers: String?,
) {
val notId = id.hashCode()
// dont notify twice
val notifications: Array<StatusBarNotification> = getActiveNotifications()
for (notification in notifications) {
if (notification.id == notId) {
return
}
}
if (isDuplicate(notId)) return
val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) }
val senderIcon = bitmap?.let { IconCompat.createWithBitmap(it) }
val sender =
Person
.Builder()
.setName(senderName)
.apply { senderIcon?.let { setIcon(it) } }
.build()
val messagingStyle =
NotificationCompat
.MessagingStyle(Person.Builder().setName("Me").build())
.addMessage(messageBody, time * 1000, sender)
val contentIntent =
Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() }
@@ -208,41 +209,198 @@ object NotificationUtils {
applicationContext,
notId,
contentIntent,
PendingIntent.FLAG_MUTABLE,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
// Build the notification
val builderPublic =
NotificationCompat
.Builder(
applicationContext,
channelId,
).setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(senderName)
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
.setLargeIcon(picture?.bitmap)
.setLargeIcon(bitmap)
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setWhen(time * 1000)
// Build the notification
val builder =
NotificationCompat
.Builder(
applicationContext,
channelId,
).setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setLargeIcon(picture?.bitmap)
.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.amethyst)
.setLargeIcon(bitmap)
.setStyle(messagingStyle)
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(DM_GROUP_KEY)
.setAutoCancel(true)
.setWhen(time * 1000)
// Direct Reply action
if (accountNpub != null && chatroomMembers != null) {
val remoteInput =
RemoteInput
.Builder(KEY_REPLY_TEXT)
.setLabel(stringRes(applicationContext, R.string.app_notification_reply_label))
.build()
val replyIntent =
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
action = REPLY_ACTION
putExtra(KEY_NOTIFICATION_ID, notId)
putExtra(KEY_ACCOUNT_NPUB, accountNpub)
putExtra(KEY_CHATROOM_MEMBERS, chatroomMembers)
}
val replyPendingIntent =
PendingIntent.getBroadcast(
applicationContext,
notId,
replyIntent,
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val replyAction =
NotificationCompat.Action
.Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
.addRemoteInput(remoteInput)
.setAllowGeneratedReplies(true)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.build()
builder.addAction(replyAction)
}
// Mark as Read action
val markReadIntent =
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
action = MARK_READ_ACTION
putExtra(KEY_NOTIFICATION_ID, notId)
}
val markReadPendingIntent =
PendingIntent.getBroadcast(
applicationContext,
notId + 1,
markReadIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val markReadAction =
NotificationCompat.Action
.Builder(R.drawable.amethyst, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.build()
builder.addAction(markReadAction)
notify(notId, builder.build())
// Group summary notification
sendGroupSummary(channelId, DM_GROUP_KEY, DM_SUMMARY_ID, stringRes(applicationContext, R.string.app_notification_dms_summary), applicationContext)
}
private suspend fun NotificationManager.sendNotification(
id: String,
messageBody: String,
messageTitle: String,
time: Long,
pictureUrl: String?,
uri: String,
channelId: String,
notificationGroupKey: String,
category: String,
summaryId: Int,
summaryText: String,
applicationContext: Context,
) {
val notId = id.hashCode()
if (isDuplicate(notId)) return
val bitmap = pictureUrl?.let { loadBitmap(it, applicationContext) }
val contentIntent =
Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() }
val contentPendingIntent =
PendingIntent.getActivity(
applicationContext,
notId,
contentIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val builderPublic =
NotificationCompat
.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
.setLargeIcon(bitmap)
.setContentIntent(contentPendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setWhen(time * 1000)
val builder =
NotificationCompat
.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(messageTitle)
.setContentText(messageBody)
.setStyle(NotificationCompat.BigTextStyle().bigText(messageBody))
.setLargeIcon(bitmap)
.setContentIntent(contentPendingIntent)
.setPublicVersion(builderPublic.build())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(category)
.setGroup(notificationGroupKey)
.setAutoCancel(true)
.setWhen(time * 1000)
notify(notId, builder.build())
sendGroupSummary(channelId, notificationGroupKey, summaryId, summaryText, applicationContext)
}
private fun NotificationManager.sendGroupSummary(
channelId: String,
groupKey: String,
summaryId: Int,
summaryText: String,
applicationContext: Context,
) {
val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId }
if (activeCount < 2) return
val summaryBuilder =
NotificationCompat
.Builder(applicationContext, channelId)
.setSmallIcon(R.drawable.amethyst)
.setGroup(groupKey)
.setGroupSummary(true)
.setAutoCancel(true)
.setStyle(
NotificationCompat
.InboxStyle()
.setSummaryText(summaryText),
)
notify(summaryId, summaryBuilder.build())
}
private fun NotificationManager.isDuplicate(notId: Int): Boolean {
val notifications: Array<StatusBarNotification> = activeNotifications
for (notification in notifications) {
if (notification.id == notId) {
return true
}
}
return false
}
/** Cancels all notifications. */
+5
View File
@@ -732,6 +732,11 @@
<string name="app_notification_zaps_channel_message_from">From %1$s</string>
<string name="app_notification_zaps_channel_message_for">for %1$s</string>
<string name="app_notification_reply_label">Reply</string>
<string name="app_notification_mark_read_label">Mark Read</string>
<string name="app_notification_dms_summary">New messages</string>
<string name="app_notification_zaps_summary">New zaps</string>
<string name="reply_notify">Notify: </string>
<string name="channel_list_join_conversation">Join Conversation</string>