From 65a6b5f8e2e7f06e915423d12bbc63994e2aa206 Mon Sep 17 00:00:00 2001 From: AliReza Taleghani Date: Fri, 12 Jun 2026 08:17:17 +0200 Subject: [PATCH] Filter notifications to communication apps + add now-playing media row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notification listener captured every notification with any text, so persistent junk (Chrome open-tab, foreground services, USB, VPN) leaked onto the home screen and music players showed only as plain text. Rework SlimNotificationListener.onNotificationPosted into an ordered pipeline: - Route media first (CATEGORY_TRANSPORT / EXTRA_MEDIA_SESSION token) so ongoing media isn't dropped by the noise filter below. - Drop ongoing / foreground-service notifications (the actual noise). - Communication-only filter (user toggle, default on): keep only message / call / email / social / missed-call categories. NotificationRegistry now tracks media separately (MediaInfo with title/artist/album-art, read via MediaController from the session token, falling back to notification extras + large icon). The Active section renders matching apps as a now-playing row: album art, "Title - Artist", and a music badge; tap still opens the player. No new layout, no extra permission — the existing notification-listener grant covers MediaController. Also: tapping the weather chip no longer jumps into Settings (easy mis-tap), and a "Communication apps only" toggle is added under a new Settings > Notifications section. Verified on device: weather tap inert; filter blocks noise when on and passes when off; Phocid now-playing row renders with art + track + badge. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/com/opscalehub/slim/MainActivity.kt | 61 ++++--- .../com/opscalehub/slim/SettingsActivity.kt | 9 + .../slim/SlimNotificationListener.kt | 154 +++++++++++++++++- .../com/opscalehub/slim/SlimPreferences.kt | 12 ++ app/src/main/res/layout/activity_settings.xml | 10 ++ app/src/main/res/values/strings.xml | 4 + 6 files changed, 224 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/opscalehub/slim/MainActivity.kt b/app/src/main/java/com/opscalehub/slim/MainActivity.kt index ffff3a1..6e90f5b 100644 --- a/app/src/main/java/com/opscalehub/slim/MainActivity.kt +++ b/app/src/main/java/com/opscalehub/slim/MainActivity.kt @@ -179,10 +179,8 @@ class MainActivity : AppCompatActivity(), WaveGestureView.OnLetterSelectedListen waveGestureView.listener = this NotificationRegistry.registerListener(this) - // Tapping the weather area opens Slim Settings (weather section) - txtWeather.setOnClickListener { - startActivity(Intent(this, SettingsActivity::class.java)) - } + // The weather chip is purely informational — tapping it does nothing. + // (It used to jump into Settings, which was an easy mis-tap.) // Tapping the header notification bell opens the system shade txtNotificationSummary.setOnClickListener { @@ -844,8 +842,8 @@ class MainActivity : AppCompatActivity(), WaveGestureView.OnLetterSelectedListen .distinct() // The settings gear used to live here, but sitting right after the last // letter it was easy to mis-tap on scrub-release. Settings is still - // reachable via the row at the end of the all-apps list, the Slim entry, - // and the weather chip — so the index stays letters-only. + // reachable via the row at the end of the all-apps list and the Slim + // entry — so the index stays letters-only. waveGestureView.setLetters(letters) } @@ -1151,22 +1149,47 @@ class MainActivity : AppCompatActivity(), WaveGestureView.OnLetterSelectedListen holder.appIcon.setImageDrawable(icon) } + val media = NotificationRegistry.getMedia(app.packageName) val preview = NotificationRegistry.getNotificationPreview(app.packageName) - if (preview != null) { - // Show preview text for favorites and the Active section, - // badge-only for plain alphabetical rows. - if (app.isFavorite || item.forceNotificationPreview) { - holder.notificationPreview.visibility = View.VISIBLE - holder.notificationPreview.text = preview - holder.notificationPreview.setTextColor(secondaryTextColor) - } else { + when { + media != null -> { + // Now-playing row: album art (when available) replaces the + // app icon, with "Title — Artist" and a ♪ badge. The icon + // block above already set the real app icon, so recycled + // rows never keep stale art. + if (showIcons && media.art != null) { + holder.appIcon.visibility = View.VISIBLE + holder.appIcon.setImageBitmap(media.art) + } + if (app.isFavorite || item.forceNotificationPreview) { + holder.notificationPreview.visibility = View.VISIBLE + holder.notificationPreview.text = + if (!media.artist.isNullOrEmpty()) "♪ ${media.title} — ${media.artist}" + else "♪ ${media.title}" + holder.notificationPreview.setTextColor(secondaryTextColor) + } else { + holder.notificationPreview.visibility = View.GONE + } + holder.notificationCount.visibility = View.VISIBLE + holder.notificationCount.text = "♪" + } + preview != null -> { + // Show preview text for favorites and the Active section, + // badge-only for plain alphabetical rows. + if (app.isFavorite || item.forceNotificationPreview) { + holder.notificationPreview.visibility = View.VISIBLE + holder.notificationPreview.text = preview + holder.notificationPreview.setTextColor(secondaryTextColor) + } else { + holder.notificationPreview.visibility = View.GONE + } + holder.notificationCount.visibility = View.VISIBLE + holder.notificationCount.text = "!" + } + else -> { holder.notificationPreview.visibility = View.GONE + holder.notificationCount.visibility = View.GONE } - holder.notificationCount.visibility = View.VISIBLE - holder.notificationCount.text = "!" - } else { - holder.notificationPreview.visibility = View.GONE - holder.notificationCount.visibility = View.GONE } holder.itemView.setOnClickListener { diff --git a/app/src/main/java/com/opscalehub/slim/SettingsActivity.kt b/app/src/main/java/com/opscalehub/slim/SettingsActivity.kt index e6dfef8..bc914e1 100644 --- a/app/src/main/java/com/opscalehub/slim/SettingsActivity.kt +++ b/app/src/main/java/com/opscalehub/slim/SettingsActivity.kt @@ -58,6 +58,7 @@ class SettingsActivity : AppCompatActivity() { bindWeatherSection() bindSearchSection() bindGesturesSection() + bindNotificationsSection() bindBackupSection() bindSystemSection() bindAboutSection() @@ -290,6 +291,14 @@ class SettingsActivity : AppCompatActivity() { } } + private fun bindNotificationsSection() { + val switchCommOnly = findViewById(R.id.switchCommNotificationsOnly) + switchCommOnly.isChecked = prefs.communicationNotificationsOnly + switchCommOnly.setOnCheckedChangeListener { _, checked -> + prefs.communicationNotificationsOnly = checked + } + } + private fun bindSystemSection() { findViewById(R.id.btnDefaultLauncher).setOnClickListener { defaultHomeLauncher.launch(DefaultLauncherHelper.requestIntent(this)) diff --git a/app/src/main/java/com/opscalehub/slim/SlimNotificationListener.kt b/app/src/main/java/com/opscalehub/slim/SlimNotificationListener.kt index 07462ae..d3f70ce 100644 --- a/app/src/main/java/com/opscalehub/slim/SlimNotificationListener.kt +++ b/app/src/main/java/com/opscalehub/slim/SlimNotificationListener.kt @@ -1,19 +1,53 @@ package com.opscalehub.slim +import android.app.Notification +import android.graphics.Bitmap +import android.media.session.MediaController +import android.media.session.MediaSession import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification -import android.app.Notification class SlimNotificationListener : NotificationListenerService() { + private val prefs by lazy { SlimPreferences(this) } + override fun onNotificationPosted(sbn: StatusBarNotification) { val packageName = sbn.packageName - val extras = sbn.notification.extras + val n = sbn.notification + + // 1. Media first — a media notification is also "ongoing", so it must be + // routed to the dedicated media row BEFORE the noise filter below, or + // the now-playing row would never appear. + if (isMedia(n)) { + val info = extractMedia(n) + if (info != null) { + NotificationRegistry.updateMedia(packageName, info) + } + return + } + + // 2. Drop ongoing / foreground-service noise (Chrome "tab open", "app + // running", VPN, USB, sync, etc.). These are persistent, not alerts. + val flags = n.flags + if (flags and Notification.FLAG_ONGOING_EVENT != 0 || + flags and Notification.FLAG_FOREGROUND_SERVICE != 0 + ) { + return + } + + // 3. Communication-only filter (user-toggleable). Keep only notifications + // a person would care to be interrupted by — messages, calls, email, + // social. Apps that fail to tag a category are filtered out in this + // mode (known tradeoff). When the toggle is off, everything that + // survived the noise filter is shown. + if (prefs.communicationNotificationsOnly && !isCommunication(n.category)) { + return + } + + // 4. Build a readable preview and register it (unchanged behavior). + val extras = n.extras val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() ?: "" val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: "" - - // Include ongoing notifications too — music players, timers, etc. - // Only skip notifications with no readable text at all. val preview = when { title.isNotEmpty() && text.isNotEmpty() -> "$title: $text" title.isNotEmpty() -> title @@ -27,15 +61,100 @@ class SlimNotificationListener : NotificationListenerService() { override fun onNotificationRemoved(sbn: StatusBarNotification) { NotificationRegistry.removeNotification(sbn.packageName) + NotificationRegistry.removeMedia(sbn.packageName) + } + + private fun isMedia(n: Notification): Boolean { + if (n.category == Notification.CATEGORY_TRANSPORT) return true + return mediaToken(n) != null + } + + @Suppress("DEPRECATION") + private fun mediaToken(n: Notification): MediaSession.Token? = + n.extras.getParcelable(Notification.EXTRA_MEDIA_SESSION) + + /** + * Pulls title / artist / album-art for the now-playing row. Prefers the + * MediaSession metadata (richest, kept in sync by the player); falls back to + * the notification's own title/text + large icon. Building a [MediaController] + * from the session token works under the existing notification-listener + * grant — no MEDIA_CONTENT_CONTROL permission needed. + */ + private fun extractMedia(n: Notification): MediaInfo? { + val token = mediaToken(n) + if (token != null) { + try { + val controller = MediaController(this, token) + val md = controller.metadata + if (md != null) { + val title = md.getString(android.media.MediaMetadata.METADATA_KEY_TITLE) + ?: md.getString(android.media.MediaMetadata.METADATA_KEY_DISPLAY_TITLE) + val artist = md.getString(android.media.MediaMetadata.METADATA_KEY_ARTIST) + ?: md.getString(android.media.MediaMetadata.METADATA_KEY_ALBUM_ARTIST) + val art = md.getBitmap(android.media.MediaMetadata.METADATA_KEY_ALBUM_ART) + ?: md.getBitmap(android.media.MediaMetadata.METADATA_KEY_ART) + if (!title.isNullOrEmpty()) { + return MediaInfo(title, artist, art) + } + } + } catch (_: Exception) { + // fall through to notification extras + } + } + val extras = n.extras + val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString() + val artist = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() + if (title.isNullOrEmpty()) return null + val art = try { + n.getLargeIcon()?.loadDrawable(this)?.let { drawableToBitmap(it) } + } catch (_: Exception) { + null + } + return MediaInfo(title, artist, art) + } + + private fun drawableToBitmap(drawable: android.graphics.drawable.Drawable): Bitmap? { + if (drawable is android.graphics.drawable.BitmapDrawable && drawable.bitmap != null) { + return drawable.bitmap + } + val w = drawable.intrinsicWidth.takeIf { it > 0 } ?: return null + val h = drawable.intrinsicHeight.takeIf { it > 0 } ?: return null + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bmp) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + return bmp + } + + private fun isCommunication(category: String?): Boolean = category in COMMUNICATION_CATEGORIES + + companion object { + // "missed_call" is referenced as a literal so the listener still compiles + // on minSdk 26 (Notification.CATEGORY_MISSED_CALL is API 33+). + private val COMMUNICATION_CATEGORIES = setOf( + Notification.CATEGORY_MESSAGE, + Notification.CATEGORY_CALL, + Notification.CATEGORY_EMAIL, + Notification.CATEGORY_SOCIAL, + "missed_call" + ) } } +/** Now-playing metadata for the media row. */ +data class MediaInfo( + val title: String, + val artist: String?, + val art: Bitmap? +) + object NotificationRegistry { interface NotificationUpdateListener { fun onNotificationsChanged() } private val activeNotifications = mutableMapOf() + private val activeMedia = mutableMapOf() private var listener: NotificationUpdateListener? = null fun registerListener(l: NotificationUpdateListener) { @@ -60,11 +179,32 @@ object NotificationRegistry { return activeNotifications[packageName] } - /** Packages with at least one active notification — used to surface them on home. */ + // ---- Media (now-playing) ---- + + fun updateMedia(packageName: String, info: MediaInfo) { + activeMedia[packageName] = info + listener?.onNotificationsChanged() + } + + fun removeMedia(packageName: String) { + if (activeMedia.remove(packageName) != null) { + listener?.onNotificationsChanged() + } + } + + fun getMedia(packageName: String): MediaInfo? { + return activeMedia[packageName] + } + + /** + * Packages with at least one active notification OR active media — used to + * surface them in the home "Active" section. + */ fun getActivePackages(): Set { - return activeNotifications.keys.toSet() + return activeNotifications.keys + activeMedia.keys } + /** Header bell count: communication notifications only, media excluded. */ fun getNotificationCount(): Int { return activeNotifications.size } diff --git a/app/src/main/java/com/opscalehub/slim/SlimPreferences.kt b/app/src/main/java/com/opscalehub/slim/SlimPreferences.kt index 843a49c..ea9650c 100644 --- a/app/src/main/java/com/opscalehub/slim/SlimPreferences.kt +++ b/app/src/main/java/com/opscalehub/slim/SlimPreferences.kt @@ -112,6 +112,17 @@ class SlimPreferences(context: Context) { get() = prefs.getBoolean(KEY_SWIPE_DOWN_NOTIFICATIONS, true) set(value) = prefs.edit().putBoolean(KEY_SWIPE_DOWN_NOTIFICATIONS, value).apply() + // ---- Notifications ---- + /** + * When true (default), only communication notifications (message/call/email/ + * social) surface on the home screen; everything else is filtered out as + * noise. Ongoing / foreground-service notifications are always dropped, and + * media gets its own row, regardless of this toggle. + */ + var communicationNotificationsOnly: Boolean + get() = prefs.getBoolean(KEY_COMM_NOTIFICATIONS_ONLY, true) + set(value) = prefs.edit().putBoolean(KEY_COMM_NOTIFICATIONS_ONLY, value).apply() + // ---- Backup / Restore ---- /** Serializes every user preference to JSON (for settings backup). */ @@ -161,6 +172,7 @@ class SlimPreferences(context: Context) { private const val KEY_SEARCH_HISTORY = "search_history" private const val KEY_SWIPE_UP_SEARCH = "swipe_up_search" private const val KEY_SWIPE_DOWN_NOTIFICATIONS = "swipe_down_notifications" + private const val KEY_COMM_NOTIFICATIONS_ONLY = "comm_notifications_only" const val BG_TRANSPARENT = "transparent" const val BG_DIMMED = "dimmed" const val BG_SOLID_BLACK = "solid_black" diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index 0f96145..34b5dd4 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -206,6 +206,16 @@ style="@style/SettingsSwitch" android:text="@string/settings_swipe_down_notifications" /> + + + + + Swipe up to open search Swipe down to open notifications + + Notifications + Communication apps only + Appearance Show app icons