feat: notification dots on app icons

New NotificationBadgeService (NotificationListenerService) tracks pending,
dismissible notifications per app. Home, favorite, and drawer icons get a
red dot in the top-right corner when the app has active notifications.

Settings: 'Notification Dots' toggle under Display features; enabling it
opens the system notification-access screen when permission is missing.
Dots update live via a StateFlow observed by the fragments.
This commit is contained in:
2026-08-19 10:53:39 +02:00
parent 0aaf00988d
commit 5288c980d0
15 changed files with 328 additions and 3 deletions

View File

@@ -103,6 +103,15 @@
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
</service>
<service
android:name=".service.NotificationBadgeService"
android:exported="true"
android:label="@string/notification_badge_service_label"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@@ -198,6 +198,10 @@ class PreferenceHelper @Inject constructor(@ApplicationContext context: Context)
get() = prefs.getBoolean(Constants.DISABLE_ANIMATIONS, false)
set(value) = prefs.edit().putBoolean(Constants.DISABLE_ANIMATIONS, value).apply()
var showNotificationDots: Boolean
get() = prefs.getBoolean(Constants.SHOW_NOTIFICATION_DOTS, false)
set(value) = prefs.edit().putBoolean(Constants.SHOW_NOTIFICATION_DOTS, value).apply()
var searchEngines: Constants.SearchEngines
get() {
return try {

View File

@@ -0,0 +1,76 @@
package com.github.droidworksstudio.launcher.service
import android.app.Notification
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Tracks pending (dismissible) notifications per app so the launcher can show
* a red dot on app icons.
*
* The user must grant notification access in the system settings
* (Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) before this service
* receives any events. Android rebinds it automatically after every boot.
*/
class NotificationBadgeService : NotificationListenerService() {
/** Notification key -> "userId/packageName". Keyed to dedupe updates. */
private val keyToPackage = HashMap<String, String>()
override fun onListenerConnected() {
super.onListenerConnected()
synchronized(keyToPackage) {
keyToPackage.clear()
activeNotifications.forEach { sbn ->
if (isCounted(sbn)) keyToPackage[sbn.key] = userIdKey(sbn)
}
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()
}
}
/** Only swipe-away, non-ongoing notifications get a dot. */
private fun isCounted(sbn: StatusBarNotification): Boolean {
val notification = sbn.notification
if (!sbn.isClearable) return false
if (notification.flags and Notification.FLAG_ONGOING_EVENT != 0) return false
return true
}
private fun userIdKey(sbn: StatusBarNotification) =
"${sbn.userId}/" + sbn.packageName
private fun publish() {
val counts = HashMap<String, Int>()
keyToPackage.values.forEach { key ->
counts[key] = (counts[key] ?: 0) + 1
}
notificationCounts.value = counts
}
companion object {
/** Active notification count per app, keyed by "userId/packageName". */
val notificationCounts: MutableStateFlow<Map<String, Int>> =
MutableStateFlow(emptyMap())
fun hasNotifications(userId: Int, packageName: String): Boolean =
notificationCounts.value["$userId/$packageName"]?.let { it > 0 } ?: false
}
}

View File

@@ -40,6 +40,7 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import kotlin.math.roundToInt
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
import com.github.droidworksstudio.launcher.utils.Constants
@@ -111,6 +112,7 @@ class DrawFragment : Fragment(),
observeClickListener()
observeSwipeTouchListener()
observeScrollTouchListener()
observeNotificationBadges()
// Initialize observation of drawer apps
observeDrawerApps()
@@ -166,6 +168,25 @@ class DrawFragment : Fragment(),
}
}
private fun observeNotificationBadges() {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
NotificationBadgeService.notificationCounts.collect {
val recyclerView = binding.drawAdapter
for (i in 0 until recyclerView.childCount) {
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
if (holder is DrawViewHolder) {
val position = holder.bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
holder.bind(drawAdapter.currentList[position])
}
}
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun observeDrawerApps() {
// Start comparing installed app information

View File

@@ -14,7 +14,9 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.databinding.ItemDrawBinding
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
class DrawViewHolder(
private val binding: ItemDrawBinding,
@@ -70,7 +72,20 @@ class DrawViewHolder(
}
appDrawIcon.layoutParams = layoutParams
appDrawIcon.setImageDrawable(appNewIcon ?: nonNullDrawable)
val baseIcon: Drawable = appNewIcon ?: nonNullDrawable
appDrawIcon.setImageDrawable(
if (preferenceHelper.showNotificationDots &&
NotificationBadgeService.hasNotifications(
appInfo.userHandle,
appInfo.packageName
)
) {
NotificationDotHelper.withDot(itemView.context, baseIcon)
} else {
baseIcon
}
)
appDrawIcon.visibility = View.VISIBLE
val parentLayout = appDrawName.parent as LinearLayoutCompat

View File

@@ -11,7 +11,9 @@ import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
@@ -28,6 +30,8 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.listener.OnItemMoveListener
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.ui.favorite.FavoriteViewHolder
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
@@ -82,6 +86,7 @@ class FavoriteFragment : Fragment(),
setupRecyclerView()
observeFavorite()
observeHomeAppOrder()
observeNotificationBadges()
observeSwipeTouchListener()
}
@@ -128,6 +133,25 @@ class FavoriteFragment : Fragment(),
}
}
private fun observeNotificationBadges() {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.CREATED) {
NotificationBadgeService.notificationCounts.collect {
val recyclerView = binding.favoriteAdapter
for (i in 0 until recyclerView.childCount) {
val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(i))
if (holder is FavoriteViewHolder) {
val position = holder.bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
holder.bind(favoriteAdapter.currentList[position])
}
}
}
}
}
}
}
private fun observeHomeAppOrder() {
binding.favoriteAdapter.adapter = favoriteAdapter
val listener: OnItemMoveListener.OnItemActionListener = favoriteAdapter

View File

@@ -11,6 +11,8 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.databinding.ItemFavoriteBinding
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
@SuppressLint("ClickableViewAccessibility")
class FavoriteViewHolder(
@@ -48,7 +50,18 @@ class FavoriteViewHolder(
if (preferenceHelper.showAppIcon) {
val appIcon =
binding.root.context.packageManager.getApplicationIcon(appInfo.packageName)
appFavoriteLeftIcon.setImageDrawable(appIcon)
val baseIcon: android.graphics.drawable.Drawable =
if (preferenceHelper.showNotificationDots &&
NotificationBadgeService.hasNotifications(
appInfo.userHandle,
appInfo.packageName
)
) {
NotificationDotHelper.withDot(binding.root.context, appIcon)
} else {
appIcon
}
appFavoriteLeftIcon.setImageDrawable(baseIcon)
appFavoriteLeftIcon.layoutParams.width =
preferenceHelper.appTextSize.toInt() * 3
appFavoriteLeftIcon.layoutParams.height =

View File

@@ -24,7 +24,9 @@ import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.NavOptions
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.StaggeredGridLayoutManager
@@ -46,6 +48,7 @@ import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.listener.OnSwipeTouchListener
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.ui.bottomsheetdialog.AppInfoBottomSheetFragment
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.viewmodel.AppViewModel
@@ -232,6 +235,25 @@ class HomeFragment : Fragment(),
}
}
private fun observeNotificationBadges() {
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])
}
}
}
}
}
}
}
@SuppressLint("ClickableViewAccessibility", "InflateParams")
private fun observeSwipeTouchListener() {
binding.apply {
@@ -584,6 +606,7 @@ class HomeFragment : Fragment(),
binding.mainView.hideKeyboard()
observeUserInterfaceSettings()
observeFavoriteAppList()
observeNotificationBadges()
}
override fun onAppClicked(appInfo: AppInfo) {

View File

@@ -15,7 +15,9 @@ import com.github.droidworksstudio.launcher.data.entities.AppInfo
import com.github.droidworksstudio.launcher.databinding.ItemHomeBinding
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.OnItemClickedListener
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.utils.NotificationDotHelper
import javax.inject.Inject
class HomeViewHolder @Inject constructor(
@@ -70,7 +72,20 @@ class HomeViewHolder @Inject constructor(
}
appHomeIcon.layoutParams = layoutParams
appHomeIcon.setImageDrawable(appNewIcon ?: nonNullDrawable)
val baseIcon: Drawable = appNewIcon ?: nonNullDrawable
appHomeIcon.setImageDrawable(
if (preferenceHelper.showNotificationDots &&
NotificationBadgeService.hasNotifications(
appInfo.userHandle,
appInfo.packageName
)
) {
NotificationDotHelper.withDot(itemView.context, baseIcon)
} else {
baseIcon
}
)
appHomeIcon.visibility = View.VISIBLE
val parentLayout = appHomeName.parent as LinearLayoutCompat

View File

@@ -1,8 +1,11 @@
package com.github.droidworksstudio.launcher.ui.settings
import android.content.Context
import android.content.ComponentName
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
@@ -18,6 +21,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.github.droidworksstudio.common.getAppNameFromPackageName
import com.github.droidworksstudio.common.showLongToast
import com.github.droidworksstudio.launcher.R
import com.github.droidworksstudio.launcher.databinding.FragmentSettingsFeaturesBinding
import com.github.droidworksstudio.launcher.helper.AppHelper
@@ -25,6 +29,7 @@ import com.github.droidworksstudio.launcher.helper.AppReloader
import com.github.droidworksstudio.launcher.helper.PreferenceHelper
import com.github.droidworksstudio.launcher.listener.ScrollEventListener
import com.github.droidworksstudio.launcher.repository.AppInfoRepository
import com.github.droidworksstudio.launcher.service.NotificationBadgeService
import com.github.droidworksstudio.launcher.utils.Constants
import com.github.droidworksstudio.launcher.viewmodel.PreferenceViewModel
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -129,6 +134,7 @@ class SettingsFeaturesFragment : Fragment(),
homeAlignmentBottomSwitchCompat.isChecked = preferenceHelper.homeAlignmentBottom
lockSettingsSwitchCompat.isChecked = preferenceHelper.settingsLock
disableAnimationsSwitchCompat.isChecked = preferenceHelper.disableAnimations
showNotificationDotsSwitchCompat.isChecked = preferenceHelper.showNotificationDots
}
}
@@ -450,10 +456,37 @@ class SettingsFeaturesFragment : Fragment(),
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
}
showNotificationDotsSwitchCompat.setOnCheckedChangeListener { _, isChecked ->
preferenceViewModel.setShowNotificationDots(isChecked)
val feedbackType = if (isChecked) "on" else "off"
appHelper.triggerHapticFeedback(context, feedbackType)
if (isChecked && !isNotificationAccessGranted()) {
openNotificationAccessSettings()
}
}
}
}
private fun isNotificationAccessGranted(): Boolean {
val componentName = ComponentName(requireContext(), NotificationBadgeService::class.java)
val notificationManager =
requireContext().getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
return notificationManager.isNotificationListenerAccessGranted(componentName)
}
private fun openNotificationAccessSettings() {
try {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
} catch (_: Exception) {
requireContext().showLongToast(
getString(R.string.toast_cannot_open_notification_access_settings)
)
}
}
private var swipeActionDialog: AlertDialog? = null
@RequiresApi(Build.VERSION_CODES.Q)

View File

@@ -70,6 +70,7 @@ object Constants {
const val HOME_ALLIGNMENT_BOTTOM = "HOME_ALLIGNMENT_BOTTOM"
const val TOGGLE_SETTING_LOCK = "TOGGLE_SETTING_LOCK"
const val DISABLE_ANIMATIONS = "DISABLE_ANIMATIONS"
const val SHOW_NOTIFICATION_DOTS = "SHOW_NOTIFICATION_DOTS"
const val HOME_DATE_ALIGNMENT = "HOME_DATE_ALIGNMENT"
const val HOME_TIME_ALIGNMENT = "HOME_TIME_ALIGNMENT"

View File

@@ -0,0 +1,51 @@
package com.github.droidworksstudio.launcher.utils
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
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.
*/
fun withDot(context: Context, icon: Drawable): Drawable {
val source = ColorIconsExtensions.drawableToBitmap(icon)
val width = source.width
val height = source.height
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
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawBitmap(source, 0f, 0f, null)
val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
}
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)
}
}

View File

@@ -49,6 +49,7 @@ class PreferenceViewModel @Inject constructor(
private val autoKeyboardLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val lockSettingsLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val disableAnimationsLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val showNotificationDotsLiveData: MutableLiveData<Boolean> = MutableLiveData()
private val appGroupPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
private val appPaddingSizeLiveData: MutableLiveData<Float> = MutableLiveData()
@@ -297,6 +298,11 @@ class PreferenceViewModel @Inject constructor(
disableAnimationsLiveData.postValue((preferenceHelper.disableAnimations))
}
fun setShowNotificationDots(showNotificationDots: Boolean) {
preferenceHelper.showNotificationDots = showNotificationDots
showNotificationDotsLiveData.postValue((preferenceHelper.showNotificationDots))
}
fun setAppLanguage(appLanguage: Constants.Language) {
preferenceHelper.appLanguage = appLanguage
appLanguageLiveData.postValue((preferenceHelper.appLanguage))

View File

@@ -238,6 +238,37 @@
tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
tools:ignore="MissingConstraints">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/showNotificationDots_text"
style="@style/TextDefaultStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="left|center"
android:text="@string/settings_display_notification_dots"
android:textSize="@dimen/text_large"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlHardcoded" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/showNotificationDots_switchCompat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleX="0.7"
android:scaleY="0.8"
android:thumb="@drawable/shape_switch_thumb"
app:track="@drawable/selector_switch"
tools:ignore="TouchTargetSizeCheck" />
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.appcompat.widget.LinearLayoutCompat

View File

@@ -110,6 +110,8 @@
<string name="settings_search_from_start">Search From Start</string>
<string name="settings_display_lock_settings">Lock Settings</string>
<string name="settings_display_disable_animations">Disable Animations</string>
<string name="settings_display_notification_dots">Notification Dots</string>
<string name="toast_cannot_open_notification_access_settings">Cannot open notification access settings.</string>
<string name="settings_appearance_text_size_title">Size</string>
<string name="settings_appearance_color_title">Color</string>
@@ -200,6 +202,7 @@
<string name="accessibility_settings_disable">Disable</string>
<string name="accessibility_service_name">Easy Launcher Actions Service</string>
<string name="notification_badge_service_label">Easy Launcher - notification dots</string>
<string name="accessibility_service_desc">Please turn on accessibility service to use double tap to lock feature in Easy Launcher.\n\nThis permission is used only to turn off your screen. Our
accessibility service does not collect or share any data.</string>