diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c9a33cb..940a58f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
+ val context = context ?: return@registerForActivityResult
+ if (granted) {
+ pendingCount?.let { DebugNotifications.setCount(context, it) }
+ if (pendingCycle) DebugNotifications.setCycling(context, true)
+ refreshDebugControls()
+ } else {
+ Toast.makeText(context, R.string.debug_notification_permission_required, Toast.LENGTH_SHORT).show()
+ refreshDebugControls()
+ }
+ pendingCount = null
+ pendingCycle = false
+ }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentAlertStatusBinding.bind(view)
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
+ binding!!.debugMinus10.setOnClickListener { changeDebugCount(-10) }
+ binding!!.debugMinus1.setOnClickListener { changeDebugCount(-1) }
+ binding!!.debugPlus1.setOnClickListener { changeDebugCount(1) }
+ binding!!.debugPlus10.setOnClickListener { changeDebugCount(10) }
+ binding!!.debugReset.setOnClickListener { setDebugCount(0) }
+ binding!!.debugCycle.setOnCheckedChangeListener { _, enabled ->
+ if (updatingCycle) return@setOnCheckedChangeListener
+ if (enabled && !DebugNotifications.hasPermission(requireContext())) {
+ pendingCycle = true
+ permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS)
+ } else {
+ DebugNotifications.setCycling(requireContext(), enabled)
+ }
+ }
}
override fun onResume() {
@@ -32,6 +66,31 @@ class AlertStatusFragment : Fragment(R.layout.fragment_alert_status) {
append(if (config.rules.count { it.enabled } == 1) "." else "s.")
}
binding?.notificationAccess?.text = if (access) "Notification access enabled" else "Enable notification access"
+ DebugNotifications.apply(context)
+ refreshDebugControls()
+ }
+
+ private fun changeDebugCount(delta: Int) = setDebugCount(DebugNotifications.count(requireContext()) + delta)
+
+ private fun setDebugCount(count: Int) {
+ val desired = count.coerceIn(0, DebugNotifications.MAX_COUNT)
+ if (desired > 0 && !DebugNotifications.hasPermission(requireContext())) {
+ pendingCount = desired
+ permissionRequest.launch(Manifest.permission.POST_NOTIFICATIONS)
+ return
+ }
+ DebugNotifications.setCount(requireContext(), desired)
+ refreshDebugControls()
+ }
+
+ private fun refreshDebugControls() {
+ val context = context ?: return
+ val count = DebugNotifications.count(context)
+ binding?.debugCount?.text = count.toString()
+ binding?.debugCycle?.isEnabled = count > 0
+ updatingCycle = true
+ binding?.debugCycle?.isChecked = DebugNotifications.isCycling(context)
+ updatingCycle = false
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt
index 520d6d9..fced1ab 100644
--- a/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/capture/NotificationCaptureService.kt
@@ -16,6 +16,7 @@ import se.ajpanton.notificationsmaster.alerts.AndroidAlertEffectPlayer
import se.ajpanton.notificationsmaster.module.AlertPolicySync
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.data.EncryptedImageStore
+import se.ajpanton.notificationsmaster.debug.DebugNotifications
import se.ajpanton.notificationsmaster.model.NotificationAction
import se.ajpanton.notificationsmaster.model.NotificationLogEntry
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
@@ -59,6 +60,7 @@ class NotificationCaptureService : NotificationListenerService() {
super.onListenerConnected()
AlertPolicySync.publishPlaybackState(this, true)
getActiveNotifications()?.forEach { sbn ->
+ if (DebugNotifications.isManaged(this, sbn)) return@forEach
val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName)
activeNotifications[snapshot.key] = snapshot
@@ -73,6 +75,7 @@ class NotificationCaptureService : NotificationListenerService() {
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
+ if (DebugNotifications.isManaged(this, sbn)) return
val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName)
val previous = activeNotifications.put(snapshot.key, snapshot)
@@ -108,6 +111,7 @@ class NotificationCaptureService : NotificationListenerService() {
rankingMap: RankingMap,
reason: Int,
) {
+ if (DebugNotifications.isManaged(this, sbn)) return
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName)
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/debug/DebugNotifications.kt b/app/src/main/java/se/ajpanton/notificationsmaster/debug/DebugNotifications.kt
new file mode 100644
index 0000000..048c8a9
--- /dev/null
+++ b/app/src/main/java/se/ajpanton/notificationsmaster/debug/DebugNotifications.kt
@@ -0,0 +1,212 @@
+package se.ajpanton.notificationsmaster.debug
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Rect
+import android.graphics.drawable.Icon
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import android.service.notification.StatusBarNotification
+import se.ajpanton.notificationsmaster.MainActivity
+import kotlin.math.abs
+
+/** Posts real, silent notifications for testing icon filtering and layout. */
+object DebugNotifications {
+ const val EXTRA_DEBUG = "se.ajpanton.notificationsmaster.DEBUG_NOTIFICATION"
+ const val MAX_COUNT = 50
+
+ private const val PREFS = "debug_notifications"
+ private const val KEY_COUNT = "count"
+ private const val KEY_CYCLE = "cycle"
+ private const val KEY_CURRENT = "current"
+ private const val KEY_DIRECTION = "direction"
+ private const val KEY_BOOT_EPOCH = "boot_epoch"
+ private const val KEY_RECOVERY_USED = "recovery_used"
+ private const val CHANNEL_ID = "debug_notification_icons"
+ private const val ID_BASE = 20_000
+ private const val GROUP_PREFIX = "notifications_master_debug_"
+ private const val CYCLE_DELAY = 2_000L
+ private const val RECOVERY_DELAY = 3_500L
+ private val handler = Handler(Looper.getMainLooper())
+ private var cycleTask: Runnable? = null
+ private var recoveryTask: Runnable? = null
+
+ fun count(context: Context) = prefs(context).getInt(KEY_COUNT, 0).coerceIn(0, MAX_COUNT)
+
+ fun isCycling(context: Context) = prefs(context).getBoolean(KEY_CYCLE, false)
+
+ fun hasPermission(context: Context) =
+ context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
+
+ fun setCount(context: Context, count: Int) {
+ prefs(context).edit()
+ .putInt(KEY_COUNT, count.coerceIn(0, MAX_COUNT))
+ .putBoolean(KEY_RECOVERY_USED, false)
+ .apply()
+ apply(context)
+ }
+
+ fun setCycling(context: Context, enabled: Boolean) {
+ prefs(context).edit()
+ .putBoolean(KEY_CYCLE, enabled)
+ .putInt(KEY_CURRENT, 0)
+ .putInt(KEY_DIRECTION, 1)
+ .putBoolean(KEY_RECOVERY_USED, false)
+ .apply()
+ apply(context)
+ }
+
+ fun apply(context: Context) {
+ val app = context.applicationContext
+ val prefs = prefs(app)
+ resetRecoveryAfterBoot(prefs)
+ val configured = count(app)
+ val desired = if (isCycling(app)) prefs.getInt(KEY_CURRENT, 0).coerceIn(0, configured) else configured
+ val manager = app.getSystemService(NotificationManager::class.java)
+ cancelAbove(app, manager, desired)
+ if (hasPermission(app)) {
+ ensureChannel(manager)
+ repeat(desired) { manager.notify(ID_BASE + it + 1, notification(app, it + 1)) }
+ scheduleRecovery(app, desired)
+ }
+ scheduleCycle(app, configured)
+ }
+
+ fun isDebug(notification: Notification) = notification.extras?.getBoolean(EXTRA_DEBUG, false) == true
+
+ fun isManaged(context: Context, sbn: StatusBarNotification) =
+ sbn.packageName == context.packageName && (
+ isDebug(sbn.notification) || sbn.id in (ID_BASE + 1)..(ID_BASE + MAX_COUNT) ||
+ (count(context) > 0 && sbn.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0)
+ )
+
+ private fun notification(context: Context, number: Int): Notification {
+ val intent = Intent(context, MainActivity::class.java)
+ val pending = PendingIntent.getActivity(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ return Notification.Builder(context, CHANNEL_ID)
+ .setSmallIcon(numberIcon(context, number))
+ .setContentTitle("Debug icon $number")
+ .setContentText("Notifications Master")
+ .setContentIntent(pending)
+ .setShowWhen(false)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setCategory(Notification.CATEGORY_STATUS)
+ .setGroup("$GROUP_PREFIX$number")
+ .setSortKey(number.toString().padStart(2, '0'))
+ .setShortcutId("$GROUP_PREFIX$number")
+ .addExtras(android.os.Bundle().apply {
+ putBoolean(EXTRA_DEBUG, true)
+ putInt("number", number)
+ })
+ .build()
+ .apply { flags = flags or Notification.FLAG_NO_CLEAR or Notification.FLAG_ONGOING_EVENT }
+ }
+
+ private fun numberIcon(context: Context, number: Int): Icon {
+ val size = (48 * context.resources.displayMetrics.density + 0.5f).toInt()
+ val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+ val background = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.HSVToColor(floatArrayOf(number * 37f % 360f, 0.6f, 0.9f))
+ }
+ canvas.drawRect(0f, 0f, size.toFloat(), size.toFloat(), background)
+ val label = number.toString()
+ val text = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ textAlign = Paint.Align.CENTER
+ isFakeBoldText = true
+ textSize = size * if (label.length > 1) 0.45f else 0.6f
+ }
+ val bounds = Rect().also { text.getTextBounds(label, 0, label.length, it) }
+ canvas.drawText(label, size / 2f, size / 2f - bounds.exactCenterY(), text)
+ return Icon.createWithBitmap(bitmap)
+ }
+
+ private fun ensureChannel(manager: NotificationManager) {
+ manager.createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, "Debug notification icons", NotificationManager.IMPORTANCE_LOW).apply {
+ description = "Silent notification icons used to test layouts"
+ setShowBadge(false)
+ lockscreenVisibility = Notification.VISIBILITY_PUBLIC
+ },
+ )
+ }
+
+ private fun cancelAbove(context: Context, manager: NotificationManager, desired: Int) {
+ manager.activeNotifications.filter { it.packageName == context.packageName }.forEach { sbn ->
+ val number = sbn.id - ID_BASE
+ if (number in 1..MAX_COUNT && number > desired) cancel(manager, sbn)
+ }
+ }
+
+ private fun scheduleCycle(context: Context, configured: Int) {
+ if (!isCycling(context) || configured == 0) {
+ cycleTask?.let(handler::removeCallbacks)
+ cycleTask = null
+ return
+ }
+ if (cycleTask != null) return
+ cycleTask = object : Runnable {
+ override fun run() {
+ val prefs = prefs(context)
+ if (!prefs.getBoolean(KEY_CYCLE, false)) {
+ cycleTask = null
+ return
+ }
+ val max = count(context)
+ var current = prefs.getInt(KEY_CURRENT, 0).coerceIn(0, max)
+ var direction = if (prefs.getInt(KEY_DIRECTION, 1) < 0) -1 else 1
+ current += direction
+ if (current >= max) { current = max; direction = -1 }
+ if (current <= 0) { current = 0; direction = 1 }
+ prefs.edit().putInt(KEY_CURRENT, current).putInt(KEY_DIRECTION, direction).apply()
+ apply(context)
+ handler.postDelayed(this, CYCLE_DELAY)
+ }
+ }.also { handler.postDelayed(it, CYCLE_DELAY) }
+ }
+
+ private fun scheduleRecovery(context: Context, desired: Int) {
+ recoveryTask?.let(handler::removeCallbacks)
+ recoveryTask = null
+ if (desired == 0 || prefs(context).getBoolean(KEY_RECOVERY_USED, false)) return
+ recoveryTask = Runnable {
+ val manager = context.getSystemService(NotificationManager::class.java)
+ val own = manager.activeNotifications.filter { it.packageName == context.packageName }
+ if (own.any { it.id !in (ID_BASE + 1)..(ID_BASE + MAX_COUNT) && !isDebug(it.notification) }) {
+ prefs(context).edit().putBoolean(KEY_RECOVERY_USED, true).apply()
+ own.forEach { cancel(manager, it) }
+ repeat(desired) { manager.notify(ID_BASE + it + 1, notification(context, it + 1)) }
+ }
+ }.also { handler.postDelayed(it, RECOVERY_DELAY) }
+ }
+
+ private fun resetRecoveryAfterBoot(prefs: android.content.SharedPreferences) {
+ val bootEpoch = System.currentTimeMillis() - SystemClock.elapsedRealtime()
+ if (abs(prefs.getLong(KEY_BOOT_EPOCH, -1) - bootEpoch) > 5_000) {
+ prefs.edit().putLong(KEY_BOOT_EPOCH, bootEpoch).putBoolean(KEY_RECOVERY_USED, false).apply()
+ }
+ }
+
+ private fun cancel(manager: NotificationManager, sbn: StatusBarNotification) {
+ sbn.tag?.let { manager.cancel(it, sbn.id) } ?: manager.cancel(sbn.id)
+ }
+
+ private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+}
diff --git a/app/src/main/res/layout/fragment_alert_status.xml b/app/src/main/res/layout/fragment_alert_status.xml
index 9f88023..23d7629 100644
--- a/app/src/main/res/layout/fragment_alert_status.xml
+++ b/app/src/main/res/layout/fragment_alert_status.xml
@@ -27,5 +27,81 @@
android:layout_marginTop="24dp"
android:text="Without optional system integration, Android keeps the source app’s own sound and vibration. Matching custom alerts play in addition; they do not replace it."
android:textSize="14sp" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index e2968fe..f243ae4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -10,6 +10,11 @@
Settings
Alerts
Alert status
+ Debug notifications
+ Post up to 50 real, silent notifications to test icon filtering and layout.
+ Cycle between zero and the selected count every two seconds
+ Reset
+ Notification permission is required for debug notifications.
Profiles
Rules
Apps