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" /> + + + + + + + + + + + +