Compare commits

..

2 Commits

Author SHA1 Message Date
GitHub Actions
5f7af04a57 chore: bump version to 4.11.139 [skip ci] 2026-07-24 05:41:58 +00:00
62b9c48fa1 Accumulate multi-message Android notifications per room.
All checks were successful
Build / increment-version (push) Successful in 5s
Build / build-android (push) Successful in 4m43s
Use MessagingStyle with persisted history so rapid messages append instead of each update wiping the previous body.
2026-07-24 15:41:50 +10:00
2 changed files with 114 additions and 15 deletions

View File

@@ -31,6 +31,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
@@ -706,6 +707,8 @@ class MatrixSyncService : Service() {
private const val GROUP_DIRECTS = "paarrot_directs" private const val GROUP_DIRECTS = "paarrot_directs"
private const val GROUP_HOME = "paarrot_home" private const val GROUP_HOME = "paarrot_home"
private const val MIN_WAKE_INTERVAL_MS = 7_500L private const val MIN_WAKE_INTERVAL_MS = 7_500L
private const val MAX_MESSAGING_HISTORY = 8
private const val KEY_MSG_HISTORY_PREFIX = "notif_hist_"
const val MODE_ONE_SHOT = "one_shot" const val MODE_ONE_SHOT = "one_shot"
/** Same hashing scheme as JS `notificationIdForRoom` so clear-on-read hits both paths. */ /** Same hashing scheme as JS `notificationIdForRoom` so clear-on-read hits both paths. */
@@ -829,13 +832,85 @@ class MatrixSyncService : Service() {
return shortcut return shortcut
} }
private data class PendingNotifMessage(
val sender: String,
val text: String,
val timestamp: Long,
)
private fun historyPrefsKey(roomId: String) = KEY_MSG_HISTORY_PREFIX + roomId
private fun loadMessageHistory(context: Context, roomId: String): List<PendingNotifMessage> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(historyPrefsKey(roomId), null)
?: return emptyList()
return try {
val arr = JSONArray(raw)
buildList {
for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue
val sender = obj.optString("sender")
val text = obj.optString("text")
val ts = obj.optLong("ts", 0L)
if (sender.isBlank() || text.isBlank()) continue
add(PendingNotifMessage(sender, text, ts))
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load notification history for $roomId: ${e.message}")
emptyList()
}
}
private fun saveMessageHistory(
context: Context,
roomId: String,
messages: List<PendingNotifMessage>,
) {
val arr = JSONArray()
for (msg in messages) {
arr.put(
JSONObject()
.put("sender", msg.sender)
.put("text", msg.text)
.put("ts", msg.timestamp)
)
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(historyPrefsKey(roomId), arr.toString())
.apply()
}
private fun clearMessageHistory(context: Context, roomId: String) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.remove(historyPrefsKey(roomId))
.apply()
}
private fun appendMessageHistory(
context: Context,
roomId: String,
sender: String,
text: String,
): List<PendingNotifMessage> {
val next = (loadMessageHistory(context, roomId) + PendingNotifMessage(
sender = sender,
text = text,
timestamp = System.currentTimeMillis(),
)).takeLast(MAX_MESSAGING_HISTORY)
saveMessageHistory(context, roomId, next)
return next
}
/** /**
* Posts a room notification (+ space/DM group summary) for both background sync * Posts a room notification (+ space/DM group summary) for both background sync
* and JS-driven Capacitor notifications. * and JS-driven Capacitor notifications.
* *
* Avatar goes in the circular largeIcon / conversation-shortcut slot (visible when * Uses MessagingStyle with persisted per-room history so rapid messages accumulate
* collapsed). The small status-bar icon stays the Paarrot mark (Android requirement). * instead of each update wiping the previous body (stable per-room notification id).
* Optional image attachments use BigPictureStyle while keeping that circular avatar. * Avatar goes in largeIcon + conversation shortcut (visible when collapsed).
*/ */
fun postMessageNotification( fun postMessageNotification(
context: Context, context: Context,
@@ -892,6 +967,8 @@ class MatrixSyncService : Service() {
} }
val senderPerson = personBuilder.build() val senderPerson = personBuilder.build()
val history = appendMessageHistory(context, roomId, resolvedSender, resolvedMessage)
val channelId = channelIdForKindStatic(kind) val channelId = channelIdForKindStatic(kind)
val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) { val collapsedTitle = if (isDm || resolvedConversation.isNullOrBlank()) {
resolvedSender resolvedSender
@@ -912,6 +989,34 @@ class MatrixSyncService : Service() {
launchIntent, launchIntent,
) )
val selfPerson = Person.Builder()
.setName("Me")
.setKey("self")
.build()
val messagingStyle = NotificationCompat.MessagingStyle(selfPerson)
.setGroupConversation(!isDm)
if (!isDm && !resolvedConversation.isNullOrBlank()) {
messagingStyle.conversationTitle = resolvedConversation
}
for (msg in history) {
val person = if (msg.sender == resolvedSender) {
senderPerson
} else {
Person.Builder()
.setName(msg.sender)
.setKey("$roomId:${msg.sender}")
.setImportant(true)
.build()
}
messagingStyle.addMessage(
NotificationCompat.MessagingStyle.Message(
msg.text,
msg.timestamp,
person,
)
)
}
val builder = NotificationCompat.Builder(context, channelId) val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_paarrot) .setSmallIcon(R.drawable.ic_stat_paarrot)
.setContentTitle(collapsedTitle) .setContentTitle(collapsedTitle)
@@ -922,11 +1027,13 @@ class MatrixSyncService : Service() {
.setCategory(NotificationCompat.CATEGORY_MESSAGE) .setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(groupId) .setGroup(groupId)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setNumber(history.size)
.setSubText(groupName) .setSubText(groupName)
.setShortcutId(shortcut.id) .setShortcutId(shortcut.id)
.setShortcutInfo(shortcut) .setShortcutInfo(shortcut)
// Prevent system "Open link in Chrome/Firefox" contextual actions on URL bodies. // Prevent system "Open link in Chrome/Firefox" contextual actions on URL bodies.
.setAllowSystemGeneratedContextualActions(false) .setAllowSystemGeneratedContextualActions(false)
.setStyle(messagingStyle)
builder.extras.putString(EXTRA_ROOM_ID, roomId) builder.extras.putString(EXTRA_ROOM_ID, roomId)
builder.extras.putString(EXTRA_GROUP_ID, groupId) builder.extras.putString(EXTRA_GROUP_ID, groupId)
@@ -937,8 +1044,8 @@ class MatrixSyncService : Service() {
// Collapsed shade avatar (large icon). Small icon must stay the monochrome app mark. // Collapsed shade avatar (large icon). Small icon must stay the monochrome app mark.
if (avatar != null) builder.setLargeIcon(avatar) if (avatar != null) builder.setLargeIcon(avatar)
if (inlineImage != null) { // Image attachments: keep MessagingStyle history; BigPicture would wipe prior lines.
// Expanded image attachment — keep avatar in the circular large-icon slot. if (inlineImage != null && history.size == 1) {
builder.setStyle( builder.setStyle(
NotificationCompat.BigPictureStyle() NotificationCompat.BigPictureStyle()
.bigPicture(inlineImage) .bigPicture(inlineImage)
@@ -946,15 +1053,6 @@ class MatrixSyncService : Service() {
.setBigContentTitle(collapsedTitle) .setBigContentTitle(collapsedTitle)
.setSummaryText(collapsedText) .setSummaryText(collapsedText)
) )
} else {
// BigTextStyle keeps setLargeIcon visible when collapsed on most OEMs.
// MessagingStyle alone often hides the avatar until the user expands.
builder.setStyle(
NotificationCompat.BigTextStyle()
.bigText(collapsedText)
.setBigContentTitle(collapsedTitle)
.setSummaryText(groupName)
)
} }
nm.notify(roomNotifId, builder.build()) nm.notify(roomNotifId, builder.build())
@@ -983,6 +1081,7 @@ class MatrixSyncService : Service() {
/** Cancel the tray notification posted for [roomId], if any. */ /** Cancel the tray notification posted for [roomId], if any. */
fun clearRoomNotifications(context: Context, roomId: String) { fun clearRoomNotifications(context: Context, roomId: String) {
if (roomId.isBlank()) return if (roomId.isBlank()) return
clearMessageHistory(context, roomId)
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val roomNotifId = notificationIdForRoom(roomId) val roomNotifId = notificationIdForRoom(roomId)

View File

@@ -1,6 +1,6 @@
{ {
"name": "paarrot", "name": "paarrot",
"version": "4.11.138", "version": "4.11.139",
"description": "Paarrot - A Matrix client based on Cinny", "description": "Paarrot - A Matrix client based on Cinny",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"