feat: notification dots follow AOSP badge count semantics

NotificationBadgeService now sums Notification.number (or 1 when unset)
per package across counted notifications, matching Launcher3 badge
behavior. A single K-9 notification with number=14 (unread count) now
yields a dot, not just one notification = one dot.
This commit is contained in:
2026-08-19 12:14:52 +02:00
parent c972125fef
commit 84fdb81061
3 changed files with 24 additions and 12 deletions

View File

@@ -3,6 +3,7 @@ package com.github.droidworksstudio.launcher.service
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -16,36 +17,43 @@ import kotlinx.coroutines.flow.StateFlow
*/
class NotificationBadgeService : NotificationListenerService() {
/** Notification key -> "userId/packageName". Keyed to dedupe updates. */
private val keyToPackage = HashMap<String, String>()
/** Notification key -> which app it belongs to and its badge number. */
private val keyToEntry = HashMap<String, BadgeEntry>()
private data class BadgeEntry(val packageKey: String, val number: Int)
override fun onListenerConnected() {
super.onListenerConnected()
synchronized(keyToPackage) {
keyToPackage.clear()
synchronized(keyToEntry) {
keyToEntry.clear()
activeNotifications.forEach { sbn ->
if (isCounted(sbn)) keyToPackage[sbn.key] = userIdKey(sbn)
if (isCounted(sbn)) keyToEntry[sbn.key] = entryFor(sbn)
}
publish()
}
Log.d("BadgeService", "connected, active=" + activeNotifications.size +
" counts=" + notificationCounts.value)
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
if (!isCounted(sbn)) return
synchronized(keyToPackage) {
synchronized(keyToEntry) {
// A posted notification with an existing key is just an update.
if (keyToPackage.containsKey(sbn.key)) return
keyToPackage[sbn.key] = userIdKey(sbn)
if (keyToEntry.containsKey(sbn.key)) return
keyToEntry[sbn.key] = entryFor(sbn)
publish()
}
}
override fun onNotificationRemoved(sbn: StatusBarNotification) {
synchronized(keyToPackage) {
if (keyToPackage.remove(sbn.key) != null) publish()
synchronized(keyToEntry) {
if (keyToEntry.remove(sbn.key) != null) publish()
}
}
private fun entryFor(sbn: StatusBarNotification) =
BadgeEntry(userIdKey(sbn), sbn.notification.number)
/** Only swipe-away, non-ongoing notifications get a dot. */
private fun isCounted(sbn: StatusBarNotification): Boolean {
val notification = sbn.notification
@@ -58,9 +66,13 @@ class NotificationBadgeService : NotificationListenerService() {
"${sbn.userId}/" + sbn.packageName
private fun publish() {
// AOSP launcher badge semantics: for each counted notification add
// its badge number (Notification.number), or 1 when unset, so e.g.
// K-9's unread-count badge shows a dot.
val counts = HashMap<String, Int>()
keyToPackage.values.forEach { key ->
counts[key] = (counts[key] ?: 0) + 1
keyToEntry.values.forEach { entry ->
val increment = entry.number.coerceAtLeast(1)
counts[entry.packageKey] = (counts[entry.packageKey] ?: 0) + increment
}
notificationCounts.value = counts
}

Binary file not shown.