Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,13 @@ object BleTransportBridge {
if (parts.size >= 2) {
val id = parts[0]
val actionName = parts[1]
// Reply text is optional and was previously dropped, so inline replies
// never worked over BLE. Empty means "plain button", not an empty reply.
val replyText = parts.getOrNull(2)?.takeIf { it.isNotEmpty() }
com.sameerasw.airsync.utils.NotificationDismissalUtil.performNotificationAction(
id,
actionName
actionName,
replyText
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,33 @@ class MediaNotificationListener : NotificationListenerService() {
} catch (e: Exception) {
Log.e(TAG, "Failed to start AirSyncService from listener", e)
}

// Re-register notifications that were already in the shade before this
// process started. Without this, actions and dismissals coming from the
// client fail with "not found" for every notification that predates the
// listener connecting. The persisted key->id mapping recovers the exact
// ID the client already holds even when the notification was updated
// since (its postTime — embedded in generated IDs — changes on update,
// while sbn.key stays stable).
try {
val currentKeys = mutableSetOf<String>()
activeNotifications?.forEach { sbn ->
currentKeys.add(sbn.key)
val title = sbn.notification?.extras?.getString(Notification.EXTRA_TITLE) ?: ""
val notificationId = NotificationDismissalUtil.getIdBySystemKey(sbn.key)
?: NotificationDismissalUtil.getPersistedIdBySystemKey(sbn.key)
?: NotificationDismissalUtil.generateNotificationId(
sbn.packageName,
title,
sbn.postTime
)
NotificationDismissalUtil.storeNotification(notificationId, sbn)
}
NotificationDismissalUtil.prunePersistedMappings(currentKeys)
} catch (e: Exception) {
Log.e(TAG, "Failed to restore active notifications on listener connect", e)
}

updateMediaInfo()
}

Expand Down Expand Up @@ -656,8 +683,10 @@ class MediaNotificationListener : NotificationListenerService() {
return@launch
}

// Retrieve existing notification ID or generate a new one
// Retrieve existing notification ID (in-memory, then persisted
// from a previous process) or generate a new one
val notificationId = NotificationDismissalUtil.getIdBySystemKey(sbn.key)
?: NotificationDismissalUtil.getPersistedIdBySystemKey(sbn.key)
?: NotificationDismissalUtil.generateNotificationId(
sbn.packageName,
title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import java.util.concurrent.ConcurrentHashMap
object NotificationDismissalUtil {
private const val TAG = "NotificationDismissalUtil"

// Persisted sbn.key -> generated ID mappings. IDs embed postTime, which
// changes when a notification is updated, while sbn.key stays stable —
// so this is what keeps client-held IDs valid across process restarts.
private const val ID_MAP_PREFS = "notification_id_mappings"

// Store active notifications with their IDs for dismissal or actions
private val activeNotifications = ConcurrentHashMap<String, StatusBarNotification>()

Expand Down Expand Up @@ -38,6 +43,7 @@ object NotificationDismissalUtil {
// Keep reverse lookup so we can map sbn.key -> id on removal
try {
keyToId[notification.key] = id
idMapPrefs()?.edit()?.putString(notification.key, id)?.apply()
} catch (_: Exception) {
}
Log.d(TAG, "Stored notification with ID: $id")
Expand All @@ -48,6 +54,7 @@ object NotificationDismissalUtil {
oldestKeys.forEach { oldId ->
activeNotifications.remove(oldId)?.let { sbn ->
keyToId.remove(sbn.key)
idMapPrefs()?.edit()?.remove(sbn.key)?.apply()
}
}
}
Expand Down Expand Up @@ -76,6 +83,7 @@ object NotificationDismissalUtil {
// Cleanup maps after cancel is requested (onNotificationRemoved may also do this)
activeNotifications.remove(notificationId)
keyToId.remove(notification.key)
idMapPrefs()?.edit()?.remove(notification.key)?.apply()
Log.d(TAG, "Successfully dismissed notification: $notificationId")
true
} else {
Expand Down Expand Up @@ -132,7 +140,7 @@ object NotificationDismissalUtil {
}

val pendingIntent = target.actionIntent
if (replyText != null) {
if (!replyText.isNullOrEmpty()) {
// Inline reply path
val remoteInputs = target.remoteInputs
if (remoteInputs.isNullOrEmpty()) {
Expand Down Expand Up @@ -180,6 +188,33 @@ object NotificationDismissalUtil {
null
}

/**
* Lookup a generated ID persisted from a previous process, by system key.
*/
fun getPersistedIdBySystemKey(systemKey: String): String? = try {
idMapPrefs()?.getString(systemKey, null)
} catch (_: Exception) {
null
}

/**
* Drop persisted mappings whose notifications are no longer active.
* Called after re-registering on listener connect.
*/
fun prunePersistedMappings(activeKeys: Set<String>) {
try {
val prefs = idMapPrefs() ?: return
val editor = prefs.edit()
prefs.all.keys.filter { it !in activeKeys }.forEach { editor.remove(it) }
editor.apply()
} catch (_: Exception) {
}
}

private fun idMapPrefs(): android.content.SharedPreferences? =
getNotificationListenerService()?.applicationContext
?.getSharedPreferences(ID_MAP_PREFS, android.content.Context.MODE_PRIVATE)

/**
* Lookup generated ID by StatusBarNotification
*/
Expand All @@ -202,6 +237,7 @@ object NotificationDismissalUtil {
fun removeFromCaches(id: String) {
activeNotifications.remove(id)?.let { sbn ->
keyToId.remove(sbn.key)
idMapPrefs()?.edit()?.remove(sbn.key)?.apply()
}
testNotificationIds.remove(id)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,9 @@ object WebSocketMessageHandler {

// We accept either "name" or legacy "action" for action name
val actionName = data.optString("name", data.optString("action", "")).ifEmpty { "" }
val replyText = data.optString("text")
// Absent "text" must stay null: an empty string would be taken as an
// inline reply and plain action buttons would never be invoked.
val replyText = data.optString("text").takeIf { it.isNotEmpty() }

if (actionName.isEmpty()) {
sendNotificationActionResponse(
Expand All @@ -411,7 +413,7 @@ object WebSocketMessageHandler {
replyText
)
val message = if (success) {
if (replyText.isNotEmpty()) "Reply sent" else "Action invoked"
if (!replyText.isNullOrEmpty()) "Reply sent" else "Action invoked"
} else {
"Failed to perform action or notification not found"
}
Expand Down