diff --git a/app/src/main/java/eu/darken/capod/common/datastore/DataStoreValue.kt b/app/src/main/java/eu/darken/capod/common/datastore/DataStoreValue.kt index bfb538e7..f5b9b040 100644 --- a/app/src/main/java/eu/darken/capod/common/datastore/DataStoreValue.kt +++ b/app/src/main/java/eu/darken/capod/common/datastore/DataStoreValue.kt @@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.log import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking @@ -23,9 +24,10 @@ class DataStoreValue( ) { val keyName: String get() = key.name - val flow: Flow = dataStore.data.map { prefs -> - reader(prefs[key]) - } + val flow: Flow = dataStore.data + .map { prefs -> prefs[key] } + .distinctUntilChanged() + .map { raw -> reader(raw) } data class Updated(val old: T, val new: T) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index 69f116b3..7668e484 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -15,7 +15,6 @@ import eu.darken.capod.common.bluetooth.BluetoothAddress import eu.darken.capod.common.bluetooth.BluetoothDevice2 import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.coroutine.DispatcherProvider -import eu.darken.capod.common.datastore.valueBlocking import eu.darken.capod.common.debug.Bugs import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE import eu.darken.capod.common.debug.logging.Logging.Priority.WARN @@ -90,6 +89,10 @@ class MonitorService : Service() { private var foregroundStartFailed = false private var injectionComplete = false + @Volatile + private var latestNotificationSettings: NotificationSettings = + NotificationSettings(useExtraNotification = false, keepAfterDisconnect = false) + @SuppressLint("InlinedApi") private fun promoteToForeground(notification: Notification): Boolean { return try { @@ -190,27 +193,53 @@ class MonitorService : Service() { } private suspend fun doMonitor() { + // Seed snapshot eagerly so onDestroy() has correct values even if we abort below. + latestNotificationSettings = NotificationSettings( + useExtraNotification = generalSettings.useExtraMonitorNotification.flow.first(), + keepAfterDisconnect = generalSettings.keepConnectedNotificationAfterDisconnect.flow.first(), + ) + val permissionsMissingOnStart = permissionTool.missingScanPermissions.first() if (permissionsMissingOnStart.isNotEmpty()) { log(TAG, WARN) { "Aborting, missing scan permissions: $permissionsMissingOnStart" } return } - val monitorJob = deviceMonitor.primaryDevice() + val deviceFlow = deviceMonitor.primaryDevice() .setupCommonEventHandlers(TAG) { "BlePodMonitor" } .distinctUntilChangedBy { it?.toNotificationKey() } .throttleLatest(1000) - .onEach { currentDevice -> - val useExtraNotification = generalSettings.useExtraMonitorNotification.valueBlocking + + val notificationSettingsFlow = combine( + generalSettings.useExtraMonitorNotification.flow, + generalSettings.keepConnectedNotificationAfterDisconnect.flow, + ) { useExtra, keepAfter -> + NotificationSettings(useExtraNotification = useExtra, keepAfterDisconnect = keepAfter) + } + + val monitorJob = combine(deviceFlow, notificationSettingsFlow) { currentDevice, settings -> + currentDevice to settings + } + .onEach { (currentDevice, settings) -> + latestNotificationSettings = settings + notificationManager.notify( MonitorNotifications.NOTIFICATION_ID, - notifications.getNotification(currentDevice, showHint = useExtraNotification), + notifications.getNotification( + currentDevice, + showHint = settings.useExtraNotification, + ), ) - if (generalSettings.useExtraMonitorNotification.valueBlocking && currentDevice != null) { - notificationManager.notify( + + when (val action = decideExtraNotificationAction(currentDevice, settings)) { + is ExtraNotificationAction.Post -> notificationManager.notify( MonitorNotifications.NOTIFICATION_ID_CONNECTED, - notifications.getNotificationConnected(currentDevice), + notifications.getNotificationConnected(action.device), ) + ExtraNotificationAction.Cancel -> notificationManager.cancel( + MonitorNotifications.NOTIFICATION_ID_CONNECTED + ) + ExtraNotificationAction.KeepExisting -> Unit } } .catch { @@ -312,7 +341,8 @@ class MonitorService : Service() { monitorScope.cancel("Service destroyed") if (injectionComplete) { - if (generalSettings.useExtraMonitorNotification.valueBlocking && !generalSettings.keepConnectedNotificationAfterDisconnect.valueBlocking) { + val snapshot = latestNotificationSettings + if (snapshot.useExtraNotification && !snapshot.keepAfterDisconnect) { try { notificationManager.cancel(MonitorNotifications.NOTIFICATION_ID_CONNECTED) } catch (e: Exception) { @@ -365,6 +395,7 @@ private data class NotificationDeviceKey( val profileId: String?, val label: String?, val model: PodModel, + val isLive: Boolean, val hasDualPods: Boolean, val hasCase: Boolean, val hasEarDetection: Boolean, @@ -389,6 +420,7 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD profileId = profileId, label = label, model = model, + isLive = isLive, hasDualPods = hasDualPods, hasCase = hasCase, hasEarDetection = hasEarDetection, @@ -408,3 +440,24 @@ private fun PodDevice.toNotificationKey(): NotificationDeviceKey = NotificationD rightPodIcon = rightPodIcon, caseIcon = caseIcon, ) + +internal data class NotificationSettings( + val useExtraNotification: Boolean, + val keepAfterDisconnect: Boolean, +) + +internal sealed interface ExtraNotificationAction { + object Cancel : ExtraNotificationAction + data class Post(val device: PodDevice) : ExtraNotificationAction + object KeepExisting : ExtraNotificationAction +} + +internal fun decideExtraNotificationAction( + device: PodDevice?, + settings: NotificationSettings, +): ExtraNotificationAction = when { + !settings.useExtraNotification -> ExtraNotificationAction.Cancel + device != null && device.isLive -> ExtraNotificationAction.Post(device) + settings.keepAfterDisconnect -> ExtraNotificationAction.KeepExisting + else -> ExtraNotificationAction.Cancel +} diff --git a/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorServiceNotificationDecisionTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorServiceNotificationDecisionTest.kt new file mode 100644 index 00000000..7df62b35 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/worker/MonitorServiceNotificationDecisionTest.kt @@ -0,0 +1,76 @@ +package eu.darken.capod.monitor.core.worker + +import eu.darken.capod.monitor.core.PodDevice +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class MonitorServiceNotificationDecisionTest : BaseTest() { + + private fun device(isLive: Boolean): PodDevice = mockk { + every { this@mockk.isLive } returns isLive + } + + private fun settings(useExtra: Boolean, keepAfter: Boolean) = NotificationSettings( + useExtraNotification = useExtra, + keepAfterDisconnect = keepAfter, + ) + + @Test + fun `extra off, null device - cancel`() { + decideExtraNotificationAction(null, settings(useExtra = false, keepAfter = false)) shouldBe + ExtraNotificationAction.Cancel + } + + @Test + fun `extra off, live device - cancel`() { + decideExtraNotificationAction(device(isLive = true), settings(useExtra = false, keepAfter = false)) shouldBe + ExtraNotificationAction.Cancel + } + + @Test + fun `extra off, cached-only device, keepAfter true - cancel (extra off wins)`() { + decideExtraNotificationAction(device(isLive = false), settings(useExtra = false, keepAfter = true)) shouldBe + ExtraNotificationAction.Cancel + } + + @Test + fun `extra on, live device, keepAfter false - post`() { + val live = device(isLive = true) + decideExtraNotificationAction(live, settings(useExtra = true, keepAfter = false)) shouldBe + ExtraNotificationAction.Post(live) + } + + @Test + fun `extra on, live device, keepAfter true - post`() { + val live = device(isLive = true) + decideExtraNotificationAction(live, settings(useExtra = true, keepAfter = true)) shouldBe + ExtraNotificationAction.Post(live) + } + + @Test + fun `extra on, cached-only device, keepAfter false - cancel`() { + decideExtraNotificationAction(device(isLive = false), settings(useExtra = true, keepAfter = false)) shouldBe + ExtraNotificationAction.Cancel + } + + @Test + fun `extra on, cached-only device, keepAfter true - keep existing`() { + decideExtraNotificationAction(device(isLive = false), settings(useExtra = true, keepAfter = true)) shouldBe + ExtraNotificationAction.KeepExisting + } + + @Test + fun `extra on, null device, keepAfter false - cancel`() { + decideExtraNotificationAction(null, settings(useExtra = true, keepAfter = false)) shouldBe + ExtraNotificationAction.Cancel + } + + @Test + fun `extra on, null device, keepAfter true - keep existing`() { + decideExtraNotificationAction(null, settings(useExtra = true, keepAfter = true)) shouldBe + ExtraNotificationAction.KeepExisting + } +}