diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt index f46d2b06..781354e2 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleTransportBridge.kt @@ -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 ) } } diff --git a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt index 0e8eca0d..1420e663 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/MediaNotificationListener.kt @@ -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() + 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() } @@ -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, diff --git a/app/src/main/java/com/sameerasw/airsync/utils/NotificationDismissalUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/NotificationDismissalUtil.kt index 0329017a..0ca66278 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/NotificationDismissalUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/NotificationDismissalUtil.kt @@ -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() @@ -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") @@ -48,6 +54,7 @@ object NotificationDismissalUtil { oldestKeys.forEach { oldId -> activeNotifications.remove(oldId)?.let { sbn -> keyToId.remove(sbn.key) + idMapPrefs()?.edit()?.remove(sbn.key)?.apply() } } } @@ -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 { @@ -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()) { @@ -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) { + 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 */ @@ -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) } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt index c6dee5de..c7da09a7 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketMessageHandler.kt @@ -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( @@ -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" }