Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 42 additions & 19 deletions app/src/main/java/com/opscalehub/slim/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/com/opscalehub/slim/SettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class SettingsActivity : AppCompatActivity() {
bindWeatherSection()
bindSearchSection()
bindGesturesSection()
bindNotificationsSection()
bindBackupSection()
bindSystemSection()
bindAboutSection()
Expand Down Expand Up @@ -290,6 +291,14 @@ class SettingsActivity : AppCompatActivity() {
}
}

private fun bindNotificationsSection() {
val switchCommOnly = findViewById<SwitchMaterial>(R.id.switchCommNotificationsOnly)
switchCommOnly.isChecked = prefs.communicationNotificationsOnly
switchCommOnly.setOnCheckedChangeListener { _, checked ->
prefs.communicationNotificationsOnly = checked
}
}

private fun bindSystemSection() {
findViewById<TextView>(R.id.btnDefaultLauncher).setOnClickListener {
defaultHomeLauncher.launch(DefaultLauncherHelper.requestIntent(this))
Expand Down
154 changes: 147 additions & 7 deletions app/src/main/java/com/opscalehub/slim/SlimNotificationListener.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<String, String>()
private val activeMedia = mutableMapOf<String, MediaInfo>()
private var listener: NotificationUpdateListener? = null

fun registerListener(l: NotificationUpdateListener) {
Expand All @@ -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<String> {
return activeNotifications.keys.toSet()
return activeNotifications.keys + activeMedia.keys
}

/** Header bell count: communication notifications only, media excluded. */
fun getNotificationCount(): Int {
return activeNotifications.size
}
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/com/opscalehub/slim/SlimPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/res/layout/activity_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,16 @@
style="@style/SettingsSwitch"
android:text="@string/settings_swipe_down_notifications" />

<!-- ======== NOTIFICATIONS ======== -->
<TextView
style="@style/SettingsSectionHeader"
android:text="@string/settings_section_notifications" />

<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchCommNotificationsOnly"
style="@style/SettingsSwitch"
android:text="@string/settings_comm_notifications_only" />

<!-- ======== BACKUP & RESTORE ======== -->
<TextView
style="@style/SettingsSectionHeader"
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@
<string name="settings_swipe_up_search">Swipe up to open search</string>
<string name="settings_swipe_down_notifications">Swipe down to open notifications</string>

<!-- Notifications -->
<string name="settings_section_notifications">Notifications</string>
<string name="settings_comm_notifications_only">Communication apps only</string>

<!-- Appearance -->
<string name="settings_section_appearance">Appearance</string>
<string name="settings_show_app_icons">Show app icons</string>
Expand Down