Add built-in debug notification generator

This commit is contained in:
ajp_anton
2026-08-29 23:54:58 +00:00
parent ce4adef117
commit 738ae25f1d
6 changed files with 357 additions and 0 deletions
@@ -1,21 +1,55 @@
package se.ajpanton.notificationsmaster
import android.Manifest
import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import android.provider.Settings
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
import se.ajpanton.notificationsmaster.databinding.FragmentAlertStatusBinding
import se.ajpanton.notificationsmaster.debug.DebugNotifications
class AlertStatusFragment : Fragment(R.layout.fragment_alert_status) {
private var binding: FragmentAlertStatusBinding? = null
private var pendingCount: Int? = null
private var pendingCycle = false
private var updatingCycle = false
private val permissionRequest = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
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() }
@@ -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)
@@ -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)
}