diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt index 3b5cea9..dc514bf 100644 --- a/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt +++ b/app/src/main/java/com/d4viddf/hyperbridge/service/NotificationReaderService.kt @@ -68,6 +68,7 @@ class NotificationReaderService : NotificationListenerService() { private val NOTIFICATION_CHANNEL_ID = "hyper_bridge_notification_channel" private val WIDGET_CHANNEL_ID = "hyper_bridge_widget_channel" private val LIVE_UPDATE_CHANNEL_ID = "hyper_bridge_live_update_channel" + private val WATCH_RELAY_CHANNEL_ID = "hyper_bridge_watch_relay_channel" private val serviceScope = CoroutineScope(Dispatchers.Default + Job()) // --- STATE & CONFIG --- @@ -97,6 +98,10 @@ class NotificationReaderService : NotificationListenerService() { private val MAX_ISLANDS = 9 private val WIDGET_ID_BASE = 9000 + // Negative so these ids can never hit the >= WIDGET_ID_BASE branch in onNotificationRemoved + private val WATCH_RELAY_ID_BASE = -20000 + private var watchRelaySlot = 0 + private val STANDARD_ISLAND_TIMEOUT_MS = 60_000L private lateinit var preferences: AppPreferences @@ -116,12 +121,19 @@ class NotificationReaderService : NotificationListenerService() { private lateinit var widgetTranslator: WidgetTranslator private lateinit var liveUpdateTranslator: LiveUpdateTranslator + @Volatile + private var isScreenOn = true + private val systemReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == Intent.ACTION_USER_UNLOCKED) { WidgetManager.init(this@NotificationReaderService) + syncNotifications(refresh = true) } else if (intent.action == Intent.ACTION_SCREEN_ON) { - syncNotifications() + isScreenOn = true + syncNotifications(refresh = true) + } else if (intent.action == Intent.ACTION_SCREEN_OFF) { + isScreenOn = false } } } @@ -159,6 +171,7 @@ class NotificationReaderService : NotificationListenerService() { val filter = IntentFilter(Intent.ACTION_USER_UNLOCKED) filter.addAction(Intent.ACTION_SCREEN_ON) + filter.addAction(Intent.ACTION_SCREEN_OFF) registerReceiver(systemReceiver, filter) val clickFilter = IntentFilter("com.d4viddf.hyperbridge.ISLAND_CLICKED") @@ -501,9 +514,14 @@ class NotificationReaderService : NotificationListenerService() { updatePermanentIsland() } - private fun handlePostNotificationSideEffects(originalKey: String, bridgeId: Int, config: IslandConfig, type: NotificationType, isLiveUpdate: Boolean) { + private fun handlePostNotificationSideEffects(originalKey: String, bridgeId: Int, config: IslandConfig, type: NotificationType, isLiveUpdate: Boolean, sbn: StatusBarNotification? = null, title: String = "", text: String = "") { // 1. Remove original if enabled (EXCEPT for Media) if (config.removeOriginalNotification == true && type != NotificationType.MEDIA && type != NotificationType.CALL) { + // Companion apps (e.g. Mi Fitness) relay notifications to watches by listening like we do; + // cancelling the original kills that relay, so post a silent short-lived copy they can forward. + if (sbn != null && !isLiveUpdate && (type == NotificationType.MESSAGE || type == NotificationType.STANDARD)) { + postWatchRelayNotification(sbn, title, text) + } intentionallyRemovedKeys.add(originalKey) cancelNotification(originalKey) } @@ -521,6 +539,35 @@ class NotificationReaderService : NotificationListenerService() { timeoutJobs.remove(originalKey) } } + } else if (type == NotificationType.MESSAGE || type == NotificationType.STANDARD) { + // HyperOS island-swipe only hides the island; the focus notification stays posted and no + // removal callback fires, so an untimed island blocks the permanent island forever. + timeoutJobs[originalKey]?.cancel() + timeoutJobs[originalKey] = serviceScope.launch { + delay(STANDARD_ISLAND_TIMEOUT_MS) + Log.d(TAG, "Island TTL reached for $originalKey, removing translated notification $bridgeId") + NotificationManagerCompat.from(this@NotificationReaderService).cancel(bridgeId) + cleanupCache(originalKey) + timeoutJobs.remove(originalKey) + } + } + } + + private fun postWatchRelayNotification(sbn: StatusBarNotification, title: String, text: String) { + try { + val appLabel = getCachedAppLabel(sbn.packageName) + val relayId = WATCH_RELAY_ID_BASE - (watchRelaySlot++ and 0x0F) + val notification = NotificationCompat.Builder(this, WATCH_RELAY_CHANNEL_ID) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(if (title.isNotBlank()) "$appLabel · $title" else appLabel) + .setContentText(text) + .setSilent(true) + .setAutoCancel(true) + .setTimeoutAfter(10_000L) + .build() + NotificationManagerCompat.from(this).notify(relayId, notification) + } catch (e: Exception) { + Log.e(TAG, "Error posting watch relay notification", e) } } @@ -771,7 +818,7 @@ class NotificationReaderService : NotificationListenerService() { ) updatePermanentIsland() - handlePostNotificationSideEffects(effectiveKey, bridgeId, finalConfig, type, true) + handlePostNotificationSideEffects(effectiveKey, bridgeId, finalConfig, type, true, sbn, effectiveTitle, effectiveText) return } @@ -814,7 +861,7 @@ class NotificationReaderService : NotificationListenerService() { ) updatePermanentIsland() - handlePostNotificationSideEffects(effectiveKey, bridgeId, finalConfig, type, false) + handlePostNotificationSideEffects(effectiveKey, bridgeId, finalConfig, type, false, sbn, effectiveTitle, effectiveText) } catch (e: Exception) { Log.e(TAG, "💥 Error processing standard notification", e) @@ -1048,6 +1095,11 @@ class NotificationReaderService : NotificationListenerService() { setSound(null, null); enableVibration(false); setShowBadge(false) } manager.createNotificationChannel(liveUpdateChannel) + + val watchRelayChannel = NotificationChannel(WATCH_RELAY_CHANNEL_ID, "Watch Relay", NotificationManager.IMPORTANCE_LOW).apply { + setSound(null, null); enableVibration(false); setShowBadge(false) + } + manager.createNotificationChannel(watchRelayChannel) } private fun shouldProcessWidgetUpdate(widgetId: Int, config: WidgetConfig): Boolean { @@ -1167,16 +1219,21 @@ class NotificationReaderService : NotificationListenerService() { override fun onListenerConnected() { Log.i(TAG, "HyperBridge Service Connected") + syncNotifications(refresh = true) syncJob?.cancel() syncJob = serviceScope.launch { while (true) { delay(60_000) // 1 minute periodic sync - syncNotifications() + // Screen off: nothing to keep in sync visually, and SCREEN_ON runs a full + // refresh sync on wake — skip the tick instead of waking up all night. + if (isScreenOn) { + syncNotifications() + } } } } - private fun syncNotifications() { + private fun syncNotifications(refresh: Boolean = false) { val now = System.currentTimeMillis() recentlyRemovedKeys.entries.removeIf { now - it.value > 10000 } @@ -1249,6 +1306,33 @@ class NotificationReaderService : NotificationListenerService() { } cleanupCache(key) } + + // Bridged notifications we no longer track (e.g. left over from a service restart) + // keep their island slot occupied forever, since island-swipe never removes them. + for (sbn in currentNotifications) { + if (sbn.packageName != packageName) continue + val id = sbn.id + if (id == PermanentIslandManager.PERMANENT_BRIDGE_ID) continue + if (id >= WIDGET_ID_BASE) continue + if (id in (WATCH_RELAY_ID_BASE - 0x0F)..WATCH_RELAY_ID_BASE) continue + if ((sbn.notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0) continue + if (reverseTranslations.containsKey(id)) continue + if (System.currentTimeMillis() - sbn.postTime < 5000) continue + Log.d(TAG, "Sync: Reaping orphan bridge notification $id") + try { + NotificationManagerCompat.from(this@NotificationReaderService).cancel(id) + } catch (_: Exception) {} + } + + val islandPresent = currentNotifications.any { + it.packageName == packageName && it.id == PermanentIslandManager.PERMANENT_BRIDGE_ID + } + permanentIslandManager.reconcile( + activeIslands.size + activeWidgets.size, + nativeIslands.isNotEmpty(), + islandPresent, + refresh + ) } catch (e: Exception) { Log.e(TAG, "Error syncing notifications", e) } diff --git a/app/src/main/java/com/d4viddf/hyperbridge/service/PermanentIslandManager.kt b/app/src/main/java/com/d4viddf/hyperbridge/service/PermanentIslandManager.kt index 4c22445..b0101ae 100644 --- a/app/src/main/java/com/d4viddf/hyperbridge/service/PermanentIslandManager.kt +++ b/app/src/main/java/com/d4viddf/hyperbridge/service/PermanentIslandManager.kt @@ -13,6 +13,8 @@ import io.github.d4viddf.hyperisland_kit.HyperIslandNotification import io.github.d4viddf.hyperisland_kit.models.ImageTextInfoLeft import io.github.d4viddf.hyperisland_kit.models.TextInfo import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -23,51 +25,109 @@ class PermanentIslandManager( private val preferences: AppPreferences ) { private val TAG = "HyperBridgeDebug" - private val PERMANENT_BRIDGE_ID = 9999 + + companion object { + const val PERMANENT_BRIDGE_ID = 9999 + // The dismiss path posts 9999 right after cancelling the previous focus + // island. Delaying the post lets HyperOS finish tearing that island down, + // otherwise it can swallow the re-post and leave 9999 posted but hidden. + private const val DISPATCH_DELAY_MS = 700L + } private var isPermanentIslandEnabled = false private var isIslandActive = false private var currentRealNotifications = 0 private var hasNativeIsland = false private var currentWidth = 0 + private var pendingDispatchJob: Job? = null init { scope.launch { preferences.isPermanentIslandEnabledFlow.collectLatest { enabled -> - if (isPermanentIslandEnabled != enabled) { - isPermanentIslandEnabled = enabled - updateState() + synchronized(this@PermanentIslandManager) { + if (isPermanentIslandEnabled != enabled) { + isPermanentIslandEnabled = enabled + updateState() + } } } } scope.launch { preferences.permanentIslandWidthFlow.collectLatest { width -> - if (currentWidth != width) { - currentWidth = width - if (isIslandActive) { - dispatchPermanentIsland() + synchronized(this@PermanentIslandManager) { + if (currentWidth != width) { + currentWidth = width + if (isIslandActive) { + dispatchPermanentIsland() + } } } } } } + @Synchronized fun onActiveNotificationsChanged(count: Int, hasNative: Boolean = false) { currentRealNotifications = count hasNativeIsland = hasNative updateState() } + // isIslandPresent reflects whether PERMANENT_BRIDGE_ID is actually posted right now. + // Presence only proves the notification exists, NOT that its island is visible: + // HyperOS can keep 9999 posted while hiding its island (e.g. a bridged focus island + // superseded it, or a re-post landed too soon after a cancel). So on a discrete + // transition (screen on / unlock / (re)connect) callers pass refresh=true to re-assert + // the island even when present; the periodic tick passes false, trusting presence. + // Bridged islands deliberately do NOT hide the permanent island: HyperOS shows the newest + // focus island on top, so keeping 9999 posted makes the permanent island reappear instantly + // when a bridged island collapses or expires (removing it would leave a gap until the TTL). + private fun desiredActive() = isPermanentIslandEnabled && !hasNativeIsland + + @Synchronized + fun reconcile(count: Int, hasNative: Boolean, isIslandPresent: Boolean, refresh: Boolean) { + currentRealNotifications = count + hasNativeIsland = hasNative + val shouldShow = desiredActive() + if (shouldShow && isIslandPresent && refresh) { + // Present but maybe not visible: re-assert in place (no remove first, so no + // rapid cancel->post to swallow). Same id + content updates the residual island. + pendingDispatchJob?.cancel() + pendingDispatchJob = null + dispatchPermanentIsland() + isIslandActive = true + return + } + isIslandActive = isIslandPresent + updateState() + } + private fun updateState() { - if (isPermanentIslandEnabled && currentRealNotifications == 0 && !hasNativeIsland) { + if (desiredActive()) { if (!isIslandActive) { - dispatchPermanentIsland() isIslandActive = true + scheduleDispatch() } } else { if (isIslandActive) { - removePermanentIsland() isIslandActive = false + pendingDispatchJob?.cancel() + pendingDispatchJob = null + removePermanentIsland() + } + } + } + + private fun scheduleDispatch() { + pendingDispatchJob?.cancel() + pendingDispatchJob = scope.launch { + delay(DISPATCH_DELAY_MS) + synchronized(this@PermanentIslandManager) { + pendingDispatchJob = null + // Re-check under the lock: the desired state may have flipped during the delay. + if (desiredActive()) { + dispatchPermanentIsland() + } } } }