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
View File
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY" /> <uses-permission android:name="se.ajpanton.notificationsmaster.permission.UPDATE_ALERT_POLICY" />
<permission <permission
@@ -1,21 +1,55 @@
package se.ajpanton.notificationsmaster package se.ajpanton.notificationsmaster
import android.Manifest
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.view.View import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore
import se.ajpanton.notificationsmaster.capture.NotificationCaptureService import se.ajpanton.notificationsmaster.capture.NotificationCaptureService
import se.ajpanton.notificationsmaster.databinding.FragmentAlertStatusBinding import se.ajpanton.notificationsmaster.databinding.FragmentAlertStatusBinding
import se.ajpanton.notificationsmaster.debug.DebugNotifications
class AlertStatusFragment : Fragment(R.layout.fragment_alert_status) { class AlertStatusFragment : Fragment(R.layout.fragment_alert_status) {
private var binding: FragmentAlertStatusBinding? = null 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentAlertStatusBinding.bind(view) binding = FragmentAlertStatusBinding.bind(view)
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } 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() { 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.") append(if (config.rules.count { it.enabled } == 1) "." else "s.")
} }
binding?.notificationAccess?.text = if (access) "Notification access enabled" else "Enable notification access" 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() } 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.module.AlertPolicySync
import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore import se.ajpanton.notificationsmaster.data.EncryptedNotificationLogStore
import se.ajpanton.notificationsmaster.data.EncryptedImageStore import se.ajpanton.notificationsmaster.data.EncryptedImageStore
import se.ajpanton.notificationsmaster.debug.DebugNotifications
import se.ajpanton.notificationsmaster.model.NotificationAction import se.ajpanton.notificationsmaster.model.NotificationAction
import se.ajpanton.notificationsmaster.model.NotificationLogEntry import se.ajpanton.notificationsmaster.model.NotificationLogEntry
import se.ajpanton.notificationsmaster.settings.LoggingRuleStore import se.ajpanton.notificationsmaster.settings.LoggingRuleStore
@@ -59,6 +60,7 @@ class NotificationCaptureService : NotificationListenerService() {
super.onListenerConnected() super.onListenerConnected()
AlertPolicySync.publishPlaybackState(this, true) AlertPolicySync.publishPlaybackState(this, true)
getActiveNotifications()?.forEach { sbn -> getActiveNotifications()?.forEach { sbn ->
if (DebugNotifications.isManaged(this, sbn)) return@forEach
val snapshot = NotificationContents.snapshot(sbn) val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName) SeenApps.markSeen(snapshot.packageName)
activeNotifications[snapshot.key] = snapshot activeNotifications[snapshot.key] = snapshot
@@ -73,6 +75,7 @@ class NotificationCaptureService : NotificationListenerService() {
} }
override fun onNotificationPosted(sbn: StatusBarNotification) { override fun onNotificationPosted(sbn: StatusBarNotification) {
if (DebugNotifications.isManaged(this, sbn)) return
val snapshot = NotificationContents.snapshot(sbn) val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName) SeenApps.markSeen(snapshot.packageName)
val previous = activeNotifications.put(snapshot.key, snapshot) val previous = activeNotifications.put(snapshot.key, snapshot)
@@ -108,6 +111,7 @@ class NotificationCaptureService : NotificationListenerService() {
rankingMap: RankingMap, rankingMap: RankingMap,
reason: Int, reason: Int,
) { ) {
if (DebugNotifications.isManaged(this, sbn)) return
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn) val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName) SeenApps.markSeen(snapshot.packageName)
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false) 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)
}
@@ -27,5 +27,81 @@
android:layout_marginTop="24dp" android:layout_marginTop="24dp"
android:text="Without optional system integration, Android keeps the source apps own sound and vibration. Matching custom alerts play in addition; they do not replace it." android:text="Without optional system integration, Android keeps the source apps own sound and vibration. Matching custom alerts play in addition; they do not replace it."
android:textSize="14sp" /> android:textSize="14sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="@string/debug_notifications_title"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/debug_notifications_description"
android:textSize="14sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/debug_cycle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/debug_cycle_notifications" />
<TextView
android:id="@+id/debug_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="0"
android:textSize="32sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<Button
android:id="@+id/debug_minus_10"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="-10" />
<Button
android:id="@+id/debug_minus_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="-1" />
<Button
android:id="@+id/debug_plus_1"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="+1" />
<Button
android:id="@+id/debug_plus_10"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:minWidth="0dp"
android:text="+10" />
</LinearLayout>
<Button
android:id="@+id/debug_reset"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/debug_reset_notifications" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
+5
View File
@@ -10,6 +10,11 @@
<string name="page_settings">Settings</string> <string name="page_settings">Settings</string>
<string name="page_alerts">Alerts</string> <string name="page_alerts">Alerts</string>
<string name="page_alert_status">Alert status</string> <string name="page_alert_status">Alert status</string>
<string name="debug_notifications_title">Debug notifications</string>
<string name="debug_notifications_description">Post up to 50 real, silent notifications to test icon filtering and layout.</string>
<string name="debug_cycle_notifications">Cycle between zero and the selected count every two seconds</string>
<string name="debug_reset_notifications">Reset</string>
<string name="debug_notification_permission_required">Notification permission is required for debug notifications.</string>
<string name="page_profiles">Profiles</string> <string name="page_profiles">Profiles</string>
<string name="page_rules">Rules</string> <string name="page_rules">Rules</string>
<string name="page_apps">Apps</string> <string name="page_apps">Apps</string>