4 Commits

Author SHA1 Message Date
da5542690d release build v0.3.6
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
Android Release CI / Build, Sign & Release (push) Has been cancelled
2026-08-19 13:14:47 +02:00
9799d3057b 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.
2026-08-19 13:14:36 +02:00
68ebe26f29 release build v0.3.5
Some checks failed
Android Main Branch CI / Build, Sign & Upload (push) Has been cancelled
Update CHANGELOG.md / changelog (push) Has been cancelled
Validate Gradle Wrapper / Validation (push) Has been cancelled
2026-08-19 12:16:44 +02:00
84fdb81061 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.
2026-08-19 12:14:52 +02:00
13 changed files with 80 additions and 49 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

@@ -19,8 +19,8 @@ android {
applicationId = "app.easy.launcher"
minSdk = 24
targetSdk = 36
versionCode = 35
versionName = "0.3.5"
versionCode = 36
versionName = "0.3.6"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders["internetPermission"] = "android.permission.INTERNET"

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,41 +17,63 @@ 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) {
synchronized(keyToEntry) {
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()
}
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
if (!isCounted(sbn)) return
synchronized(keyToPackage) {
// A posted notification with an existing key is just an update.
if (keyToPackage.containsKey(sbn.key)) return
keyToPackage[sbn.key] = userIdKey(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()
}
}
/** Only swipe-away, non-ongoing notifications get a dot. */
private fun entryFor(sbn: StatusBarNotification) =
BadgeEntry(userIdKey(sbn), sbn.notification.number)
/** 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
}
@@ -58,11 +81,16 @@ 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
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)
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
dist/EasyLauncher-v0.3.6-Signed.apk vendored Normal file

Binary file not shown.

Binary file not shown.