Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da5542690d | |||
| 9799d3057b | |||
| 68ebe26f29 | |||
| 84fdb81061 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk
vendored
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk
vendored
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk.idsig
vendored
BIN
dist/EasyLauncher-Internet-v0.3.5-Signed.apk.idsig
vendored
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-Internet-v0.3.6-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.5-Signed.apk
vendored
BIN
dist/EasyLauncher-v0.3.5-Signed.apk
vendored
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.5-Signed.apk.idsig
vendored
BIN
dist/EasyLauncher-v0.3.5-Signed.apk.idsig
vendored
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.6-Signed.apk
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.6-Signed.apk
vendored
Normal file
Binary file not shown.
BIN
dist/EasyLauncher-v0.3.6-Signed.apk.idsig
vendored
Normal file
BIN
dist/EasyLauncher-v0.3.6-Signed.apk.idsig
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user