fix: notification dots render reliably

- NotificationBadgeService: re-posted notifications update the stored
  badge number instead of being dropped; flag changes (clearable ->
  ongoing) remove the entry; exclude group summaries (AOSP Launcher3
  does too) and USER_ALL (-1) records; log each counts publish.
- HomeFragment: register the badge collector once in onViewCreated
  instead of every onResume (collector leak); rebind the visible home
  rows immediately when the notification-dots toggle flips.
- NotificationDotHelper: plain red dot without the white outline;
  position tangent to the top-right corner so the circle is never
  clipped by the icon bitmap.
This commit is contained in:
2026-08-19 13:14:36 +02:00
parent 68ebe26f29
commit 9799d3057b
4 changed files with 51 additions and 32 deletions

View File

@@ -86,6 +86,9 @@ Prefs live in `/data/data/app.easy.launcher/shared_prefs/`. To change them:
- Service: `.service.NotificationBadgeService` (NotificationListenerService).
Users must grant notification access; the "Notification Dots" settings toggle
opens `ACTION_NOTIFICATION_LISTENER_SETTINGS` when missing.
- Badge semantics: per package the service sums `Notification.number` (or 1
when unset) over COUNTED notifications — same as AOSP Launcher3. Only
swipe-away, non-ongoing notifications are counted (music/FGS excluded).
- Grant from adb:
`cmd notification allow_listener app.easy.launcher/com.github.droidworksstudio.launcher.service.NotificationBadgeService`
- Test notification: `cmd notification post -t 'Title' tag 'body'` — posts as

View File

@@ -36,11 +36,21 @@ class NotificationBadgeService : NotificationListenerService() {
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
if (!isCounted(sbn)) return
synchronized(keyToEntry) {
// A posted notification with an existing key is just an update.
if (keyToEntry.containsKey(sbn.key)) return
keyToEntry[sbn.key] = entryFor(sbn)
val wasCounted = keyToEntry.containsKey(sbn.key)
if (isCounted(sbn)) {
// Re-posts are in-place updates: refresh the stored badge
// number and flags instead of dropping them, so apps that
// update one notification (e.g. K-9 unread count) stay fresh.
keyToEntry[sbn.key] = entryFor(sbn)
} else if (wasCounted) {
// The notification became non-counted (e.g. now ongoing or
// group summary); drop it rather than keep a stale entry.
keyToEntry.remove(sbn.key)
} else {
// Never counted and not counted now: fast path, no publish.
return
}
publish()
}
}
@@ -54,11 +64,16 @@ class NotificationBadgeService : NotificationListenerService() {
private fun entryFor(sbn: StatusBarNotification) =
BadgeEntry(userIdKey(sbn), sbn.notification.number)
/** Only swipe-away, non-ongoing notifications get a dot. */
/** Only swipe-away, non-ongoing, non-summary notifications get a dot. */
private fun isCounted(sbn: StatusBarNotification): Boolean {
// USER_ALL notifications (userId -1) can never match a launcher icon.
if (sbn.userId < 0) return false
val notification = sbn.notification
if (!sbn.isClearable) return false
if (notification.flags and Notification.FLAG_ONGOING_EVENT != 0) return false
// Group summaries aggregate their children (AOSP Launcher3 excludes
// them too); counting them double-counts a group's messages.
if (notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) return false
return true
}
@@ -75,6 +90,7 @@ class NotificationBadgeService : NotificationListenerService() {
counts[entry.packageKey] = (counts[entry.packageKey] ?: 0) + increment
}
notificationCounts.value = counts
Log.d("BadgeService", "publish counts=" + counts)
}
companion object {

View File

@@ -116,6 +116,7 @@ class HomeFragment : Fragment(),
setupRecyclerView()
observeSwipeTouchListener()
observeUserInterfaceSettings()
observeNotificationBadges()
// Nerd-font weather glyphs (nf-weather-*) live in the bundled
// subset font; the system font here is JetBrainsMonoNerdFont but
@@ -247,16 +248,21 @@ class HomeFragment : Fragment(),
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
NotificationBadgeService.notificationCounts.collect {
val recyclerView = binding.appListAdapter
for (i in 0 until recyclerView.childCount) {
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
if (holder is HomeViewHolder) {
val position = holder.bindingAdapterPosition
if (position != androidx.recyclerview.widget.RecyclerView.NO_POSITION) {
holder.bind(homeAdapter.currentList[position])
}
}
}
rebindVisibleHomeRows()
}
}
}
}
/** Re-bind the visible home rows so dots appear/disappear immediately. */
private fun rebindVisibleHomeRows() {
val recyclerView = binding.appListAdapter
for (i in 0 until recyclerView.childCount) {
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
if (holder is HomeViewHolder) {
val position = holder.bindingAdapterPosition
if (position != androidx.recyclerview.widget.RecyclerView.NO_POSITION) {
holder.bind(homeAdapter.currentList[position])
}
}
}
@@ -343,6 +349,12 @@ class HomeFragment : Fragment(),
if (it) loadCurrentWeather()
}
// Flipping the notification-dots toggle must re-render the home icons
// immediately (the HomeFragment stays alive under the settings screen).
preferenceViewModel.showNotificationDotsLiveData.observe(viewLifecycleOwner) {
rebindVisibleHomeRows()
}
binding.apply {
mainView.hideKeyboard()
@@ -661,7 +673,6 @@ class HomeFragment : Fragment(),
binding.mainView.hideKeyboard()
observeUserInterfaceSettings()
observeFavoriteAppList()
observeNotificationBadges()
if (preferenceHelper.showCurrentWeather) loadCurrentWeather()
}

View File

@@ -12,8 +12,8 @@ import com.github.droidworksstudio.common.ColorIconsExtensions
object NotificationDotHelper {
/**
* Returns a copy of [icon] with a red dot drawn in its top-right corner,
* surrounded by a thin white border so it stays visible on busy icons.
* Returns a copy of [icon] with a plain red dot drawn in its top-right
* corner.
*/
fun withDot(context: Context, icon: Drawable): Drawable {
val source = ColorIconsExtensions.drawableToBitmap(icon)
@@ -22,8 +22,9 @@ object NotificationDotHelper {
if (width <= 0 || height <= 0) return icon
val dotRadius = (minOf(width, height) * 0.22f).coerceAtLeast(4f)
val centerX = width - dotRadius * 0.75f
val centerY = dotRadius * 0.75f
// Tangent to the top and right edges so the dot never gets clipped.
val centerX = width - dotRadius
val centerY = dotRadius
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
@@ -34,18 +35,6 @@ object NotificationDotHelper {
}
canvas.drawCircle(centerX, centerY, dotRadius, dotPaint)
val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.STROKE
strokeWidth = (dotRadius * 0.28f).coerceAtLeast(1.5f)
}
canvas.drawCircle(
centerX,
centerY,
dotRadius - borderPaint.strokeWidth / 2f,
borderPaint
)
return BitmapDrawable(context.resources, bitmap)
}
}