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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user