diff --git a/CLAUDE.md b/CLAUDE.md index 7d811a1..d4ea22f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/service/NotificationBadgeService.kt b/app/src/main/java/com/github/droidworksstudio/launcher/service/NotificationBadgeService.kt index 2ece4a5..2c5bcd8 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/service/NotificationBadgeService.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/service/NotificationBadgeService.kt @@ -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 { diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/ui/home/HomeFragment.kt b/app/src/main/java/com/github/droidworksstudio/launcher/ui/home/HomeFragment.kt index 35a6b85..92acc7b 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/ui/home/HomeFragment.kt @@ -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() } diff --git a/app/src/main/java/com/github/droidworksstudio/launcher/utils/NotificationDotHelper.kt b/app/src/main/java/com/github/droidworksstudio/launcher/utils/NotificationDotHelper.kt index 34b71ef..4137c82 100644 --- a/app/src/main/java/com/github/droidworksstudio/launcher/utils/NotificationDotHelper.kt +++ b/app/src/main/java/com/github/droidworksstudio/launcher/utils/NotificationDotHelper.kt @@ -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) } }