diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/AncGlanceWidget.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/AncGlanceWidget.kt index 4aa63247..550f3853 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/AncGlanceWidget.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/AncGlanceWidget.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.annotation.Keep import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.glance.GlanceId import androidx.glance.LocalSize import androidx.glance.appwidget.GlanceAppWidget @@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.isPro import eu.darken.capod.monitor.core.DeviceMonitor -import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.profiles.core.DeviceProfilesRepo +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking class AncGlanceWidget : GlanceAppWidget() { @@ -43,21 +45,15 @@ class AncGlanceWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val ep: AncWidgetEntryPoint val appWidgetId: Int - val initialIsPro: Boolean - val initialProfileId: String? - val cachedDevice: PodDevice? try { ep = EntryPointAccessors.fromApplication(context, AncWidgetEntryPoint::class.java) appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" } - initialIsPro = ep.upgradeRepo().isPro() ep.widgetSettings().migrateLegacyConfigIfNeeded( appWidgetId, AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId), ) - initialProfileId = ep.widgetSettings().getWidgetConfig(appWidgetId).profileId - cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) } } catch (e: Exception) { log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" } provideContent { @@ -77,25 +73,40 @@ class AncGlanceWidget : GlanceAppWidget() { } provideContent { - val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList()) - val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList()) - val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null) val widthDp = LocalSize.current.width val heightDp = LocalSize.current.height - val state = try { - val config = ep.widgetSettings().getWidgetConfig(appWidgetId) - val profileId = config.profileId - val theme = config.theme + // Glance keeps the content session alive and does not restart provideGlance() + // for every update(). Observe a widget-key-deduped device flow so visible state + // changes update active sessions without recomposing on every BLE advertisement. + val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) } + .onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } } + .getOrNull() - val isPro = upgradeInfo?.isPro ?: initialIsPro - - val liveDevice = devices.firstOrNull { it.profileId == profileId } - val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId } - - val profileLabel = profileId?.let { pid -> - profiles.firstOrNull { it.id == pid }?.label + val state = if (config != null) { + val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } } + .onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } } + .getOrDefault(false) + val initialDevice = remember(config.profileId) { + config.profileId?.let { pid -> + runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } } + .onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } } + .getOrNull() + } } + val device by config.profileId + ?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } } + ?.collectAsState(initial = initialDevice) + ?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) } + val profileLabel = config.profileId?.let { pid -> + runCatching { + runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label } + } + .onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } } + .getOrNull() + } + + log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" } val widthCells = getCellsForSize(widthDp.value.toInt()) val heightCells = getCellsForSize(heightDp.value.toInt()) @@ -111,14 +122,13 @@ class AncGlanceWidget : GlanceAppWidget() { AncWidgetRenderStateMapper.map( context = context, device = device, - theme = theme, + theme = config.theme, isPro = isPro, - hasConfiguredProfile = profileId != null, + hasConfiguredProfile = config.profileId != null, profileLabel = profileLabel, layout = layout, ) - } catch (e: Exception) { - log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" } + } else { AncWidgetRenderState.Message( theme = WidgetTheme.DEFAULT, resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT), diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/AncModeActionCallback.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/AncModeActionCallback.kt index 1c8a344a..01182b38 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/AncModeActionCallback.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/AncModeActionCallback.kt @@ -5,7 +5,6 @@ import androidx.annotation.Keep import androidx.glance.GlanceId import androidx.glance.action.ActionParameters import androidx.glance.appwidget.action.ActionCallback -import androidx.glance.appwidget.updateAll import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.EntryPointAccessors @@ -30,6 +29,7 @@ class AncModeActionCallback : ActionCallback { fun aapConnectionManager(): AapConnectionManager fun widgetSettings(): WidgetSettings fun deviceMonitor(): DeviceMonitor + fun widgetManager(): WidgetManager } override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { @@ -62,14 +62,14 @@ class AncModeActionCallback : ActionCallback { val device = ep.deviceMonitor().getDeviceForProfile(profileId) if (device == null) { log(TAG, ERROR) { "onAction: no device for profileId=$profileId" } - AncGlanceWidget().updateAll(context) + ep.widgetManager().refreshWidgets() return } val address = device.address if (address == null) { log(TAG, ERROR) { "onAction: device has no address" } - AncGlanceWidget().updateAll(context) + ep.widgetManager().refreshWidgets() return } @@ -80,7 +80,7 @@ class AncModeActionCallback : ActionCallback { log(TAG, ERROR) { "onAction: sendCommand failed: ${e.asLog()}" } } - AncGlanceWidget().updateAll(context) + ep.widgetManager().refreshWidgets() } companion object { diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt index 43a1018b..40dfb4b9 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/BatteryGlanceWidget.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.annotation.Keep import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.glance.GlanceId import androidx.glance.LocalSize import androidx.glance.appwidget.GlanceAppWidget @@ -23,8 +24,9 @@ import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.upgrade.UpgradeRepo import eu.darken.capod.common.upgrade.isPro import eu.darken.capod.monitor.core.DeviceMonitor -import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.profiles.core.DeviceProfilesRepo +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking class BatteryGlanceWidget : GlanceAppWidget() { @@ -43,21 +45,15 @@ class BatteryGlanceWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val ep: WidgetEntryPoint val appWidgetId: Int - val initialIsPro: Boolean - val initialProfileId: String? - val cachedDevice: PodDevice? try { ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java) appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" } - initialIsPro = ep.upgradeRepo().isPro() ep.widgetSettings().migrateLegacyConfigIfNeeded( appWidgetId, AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId), ) - initialProfileId = ep.widgetSettings().getWidgetConfig(appWidgetId).profileId - cachedDevice = initialProfileId?.let { ep.deviceMonitor().getDeviceForProfile(it) } } catch (e: Exception) { log(TAG, ERROR) { "provideGlance setup failed: ${e.asLog()}" } provideContent { @@ -78,38 +74,51 @@ class BatteryGlanceWidget : GlanceAppWidget() { } provideContent { - // Composable reads — must be outside try-catch - val devices by ep.deviceMonitor().devices.collectAsState(initial = emptyList()) - val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList()) - val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null) val widthDp = LocalSize.current.width val layout = BatteryLayout.forCells(getCellsForSize(widthDp.value.toInt())) - val state = try { - val config = ep.widgetSettings().getWidgetConfig(appWidgetId) - val profileId = config.profileId - val theme = config.theme + // Glance keeps the content session alive and does not restart provideGlance() + // for every update(). Observe a widget-key-deduped device flow so visible state + // changes update active sessions without recomposing on every BLE advertisement. + val config = runCatching { ep.widgetSettings().getWidgetConfig(appWidgetId) } + .onFailure { e -> log(TAG, ERROR) { "getWidgetConfig failed: ${e.asLog()}" } } + .getOrNull() - val isPro = upgradeInfo?.isPro ?: initialIsPro - - val liveDevice = devices.firstOrNull { it.profileId == profileId } - val device = liveDevice ?: cachedDevice?.takeIf { it.profileId == profileId } - - val profileLabel = profileId?.let { pid -> - profiles.firstOrNull { it.id == pid }?.label + val state = if (config != null) { + val isPro = runCatching { runBlocking { ep.upgradeRepo().isPro() } } + .onFailure { e -> log(TAG, ERROR) { "isPro failed: ${e.asLog()}" } } + .getOrDefault(false) + val initialDevice = remember(config.profileId) { + config.profileId?.let { pid -> + runCatching { runBlocking { ep.deviceMonitor().getDeviceForProfile(pid) } } + .onFailure { e -> log(TAG, ERROR) { "initial device lookup failed: ${e.asLog()}" } } + .getOrNull() + } } + val device by config.profileId + ?.let { pid -> remember(pid) { ep.deviceMonitor().widgetDeviceFlow(pid) } } + ?.collectAsState(initial = initialDevice) + ?: remember(initialDevice) { androidx.compose.runtime.mutableStateOf(initialDevice) } + val profileLabel = config.profileId?.let { pid -> + runCatching { + runBlocking { ep.deviceProfilesRepo().profiles.first().firstOrNull { it.id == pid }?.label } + } + .onFailure { e -> log(TAG, ERROR) { "profile label lookup failed: ${e.asLog()}" } } + .getOrNull() + } + + log(TAG, VERBOSE) { "render(appWidgetId=$appWidgetId, deviceKey=${device?.toWidgetKey()})" } WidgetRenderStateMapper.map( context = context, device = device, - theme = theme, + theme = config.theme, isPro = isPro, - hasConfiguredProfile = profileId != null, + hasConfiguredProfile = config.profileId != null, profileLabel = profileLabel, layout = layout, ) - } catch (e: Exception) { - log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" } + } else { WidgetRenderState.Message( theme = WidgetTheme.DEFAULT, resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT), diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceFlow.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceFlow.kt new file mode 100644 index 00000000..cd7f5445 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceFlow.kt @@ -0,0 +1,11 @@ +package eu.darken.capod.main.ui.widget + +import eu.darken.capod.monitor.core.DeviceMonitor +import eu.darken.capod.monitor.core.PodDevice +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.map + +internal fun DeviceMonitor.widgetDeviceFlow(profileId: String): Flow = devices + .map { devices -> devices.firstOrNull { it.profileId == profileId } } + .distinctUntilChangedBy { it?.toWidgetKey() } diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt index 9c2d4e67..04da1c4e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetDeviceKey.kt @@ -1,16 +1,18 @@ package eu.darken.capod.main.ui.widget import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.monitor.core.visibleAncModes import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting /** * Snapshot of the [PodDevice] fields that affect widget rendering. Used to - * gate widget refreshes so RSSI / reliability / scan-counter churn doesn't - * cause a refresh on every BLE advertisement. + * gate widget refreshes so RSSI / reliability / scan-counter / timestamp churn + * doesn't cause a refresh on every BLE advertisement. */ internal data class WidgetDeviceKey( val profileId: String?, + val profileLabel: String?, val model: PodModel, val batteryLeft: Float?, val batteryRight: Float?, @@ -20,12 +22,19 @@ internal data class WidgetDeviceKey( val isRightPodCharging: Boolean?, val isCaseCharging: Boolean?, val isHeadsetBeingCharged: Boolean?, + val isLeftInEar: Boolean?, + val isRightInEar: Boolean?, + val isBeingWorn: Boolean?, + val isAapConnected: Boolean, + val isAapReady: Boolean, val ancMode: AapSetting.AncMode.Value?, val pendingAncMode: AapSetting.AncMode.Value?, + val visibleAncModes: List, ) internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey( profileId = profileId, + profileLabel = label, model = model, batteryLeft = batteryLeft, batteryRight = batteryRight, @@ -35,6 +44,12 @@ internal fun PodDevice.toWidgetKey(): WidgetDeviceKey = WidgetDeviceKey( isRightPodCharging = isRightPodCharging, isCaseCharging = isCaseCharging, isHeadsetBeingCharged = isHeadsetBeingCharged, + isLeftInEar = isLeftInEar, + isRightInEar = isRightInEar, + isBeingWorn = isBeingWorn, + isAapConnected = isAapConnected, + isAapReady = isAapReady, ancMode = ancMode?.current, pendingAncMode = pendingAncMode, + visibleAncModes = visibleAncModes, ) diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetManager.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetManager.kt index 1e2050f0..9acbddaf 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetManager.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/WidgetManager.kt @@ -1,8 +1,18 @@ package eu.darken.capod.main.ui.widget +import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context -import androidx.glance.appwidget.updateAll +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager import dagger.hilt.android.qualifiers.ApplicationContext +import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR +import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE +import eu.darken.capod.common.debug.logging.Logging.Priority.WARN +import eu.darken.capod.common.debug.logging.asLog +import eu.darken.capod.common.debug.logging.log +import eu.darken.capod.common.debug.logging.logTag import javax.inject.Inject import javax.inject.Singleton @@ -13,7 +23,66 @@ class WidgetManager @Inject constructor( ) { suspend fun refreshWidgets() { - BatteryGlanceWidget().updateAll(context) - AncGlanceWidget().updateAll(context) + val manager = GlanceAppWidgetManager(context) + refreshWidget( + name = "battery", + widget = BatteryGlanceWidget(), + providerClass = BatteryGlanceWidget::class.java, + receiverClass = WidgetProvider::class.java, + manager = manager, + ) + refreshWidget( + name = "anc", + widget = AncGlanceWidget(), + providerClass = AncGlanceWidget::class.java, + receiverClass = AncWidgetProvider::class.java, + manager = manager, + ) + } + + private suspend fun refreshWidget( + name: String, + widget: T, + providerClass: Class, + receiverClass: Class<*>, + manager: GlanceAppWidgetManager, + ) { + val ids = manager.getGlanceIds(providerClass).ifEmpty { + val appWidgetIds = AppWidgetManager.getInstance(context) + .getAppWidgetIds(ComponentName(context, receiverClass)) + .toList() + if (appWidgetIds.isNotEmpty()) { + log(TAG, WARN) { "refresh($name): Glance IDs empty, falling back to platform ids=$appWidgetIds" } + } + appWidgetIds.mapNotNull { appWidgetId -> + runCatching { manager.getGlanceIdBy(appWidgetId) } + .onFailure { error -> + log(TAG, ERROR) { "refresh($name): failed to resolve widgetId=$appWidgetId: ${error.asLog()}" } + } + .getOrNull() + } + } + + log(TAG, VERBOSE) { "refresh($name): ids=${ids.toAppWidgetIds(manager)}" } + + ids.forEach { id -> + runCatching { widget.update(context, id) } + .onFailure { error -> + log(TAG, ERROR) { "refresh($name): update failed for id=${id.toAppWidgetId(manager)}: ${error.asLog()}" } + } + } + } + + private fun List.toAppWidgetIds(manager: GlanceAppWidgetManager): List = + map { it.toAppWidgetId(manager) } + + private fun GlanceId.toAppWidgetId(manager: GlanceAppWidgetManager): Int = runCatching { + manager.getAppWidgetId(this) + }.getOrElse { + AppWidgetManager.INVALID_APPWIDGET_ID + } + + companion object { + val TAG = logTag("Widget", "Manager") } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 42f8b457..6b143a8a 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -205,18 +205,27 @@ data class PodDevice( } } ?: (ble as? DualApplePods)?.primaryPod + private fun AapSetting.EarDetection.PodPlacement.isInEar(): Boolean = + this == AapSetting.EarDetection.PodPlacement.IN_EAR + + private fun AapSetting.EarDetection.samePlacementInEarOrNull(): Boolean? = + if (primaryPod == secondaryPod) primaryPod.isInEar() else null + // Ear detection — AAP preferred (lower latency), BLE fallback. // AAP reports primary/secondary; resolvedPrimaryPod tells us which physical pod is primary. val isLeftInEar: Boolean? get() { val earDetection = aap?.aapEarDetection val primary = resolvedPrimaryPod - if (earDetection != null && primary != null) { - return if (primary == DualBlePodSnapshot.Pod.LEFT) { - earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR - } else { - earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR + if (earDetection != null) { + if (primary != null) { + return if (primary == DualBlePodSnapshot.Pod.LEFT) { + earDetection.primaryPod.isInEar() + } else { + earDetection.secondaryPod.isInEar() + } } + earDetection.samePlacementInEarOrNull()?.let { return it } } return (ble as? HasEarDetectionDual)?.isLeftPodInEar } @@ -225,12 +234,15 @@ data class PodDevice( get() { val earDetection = aap?.aapEarDetection val primary = resolvedPrimaryPod - if (earDetection != null && primary != null) { - return if (primary == DualBlePodSnapshot.Pod.RIGHT) { - earDetection.primaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR - } else { - earDetection.secondaryPod == AapSetting.EarDetection.PodPlacement.IN_EAR + if (earDetection != null) { + if (primary != null) { + return if (primary == DualBlePodSnapshot.Pod.RIGHT) { + earDetection.primaryPod.isInEar() + } else { + earDetection.secondaryPod.isInEar() + } } + earDetection.samePlacementInEarOrNull()?.let { return it } } return (ble as? HasEarDetectionDual)?.isRightPodInEar } diff --git a/app/src/test/java/eu/darken/capod/main/ui/widget/WidgetDeviceKeyTest.kt b/app/src/test/java/eu/darken/capod/main/ui/widget/WidgetDeviceKeyTest.kt new file mode 100644 index 00000000..0f3d4ecf --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/widget/WidgetDeviceKeyTest.kt @@ -0,0 +1,222 @@ +package eu.darken.capod.main.ui.widget + +import eu.darken.capod.common.bluetooth.BleScanResult +import eu.darken.capod.monitor.core.PodDevice +import eu.darken.capod.pods.core.apple.PodModel +import eu.darken.capod.pods.core.apple.aap.AapPodState +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot +import eu.darken.capod.pods.core.apple.ble.devices.HasCase +import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetection +import eu.darken.capod.pods.core.apple.ble.devices.HasChargeDetectionDual +import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetection +import eu.darken.capod.pods.core.apple.ble.devices.HasEarDetectionDual +import eu.darken.capod.profiles.core.AppleDeviceProfile +import eu.darken.capod.profiles.core.DeviceProfile +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.time.Instant + +class WidgetDeviceKeyTest : BaseTest() { + + private val dualProfile = AppleDeviceProfile( + id = "dual-profile", + label = "Office AirPods", + model = PodModel.AIRPODS_PRO3, + address = "AA:BB:CC:DD:EE:FF", + ) + + private val singleProfile = AppleDeviceProfile( + id = "single-profile", + label = "Desk AirPods Max", + model = PodModel.AIRPODS_MAX, + address = "11:22:33:44:55:66", + ) + + @Test + fun `scan noise does not change key`() { + val first = dualDevice( + ble = FakeDualBlePod( + profile = dualProfile, + rssiValue = -59, + reliability = 0.64f, + seenCounter = 19, + seenLastAt = Instant.parse("2026-04-05T18:00:00Z"), + ) + ) + val second = dualDevice( + ble = FakeDualBlePod( + profile = dualProfile, + rssiValue = -54, + reliability = 1.0f, + seenCounter = 31, + seenLastAt = Instant.parse("2026-04-05T18:00:12Z"), + ) + ) + + first.toWidgetKey() shouldBe second.toWidgetKey() + } + + @Test + fun `battery and charging changes alter key`() { + val base = dualDevice().toWidgetKey() + + dualDevice(ble = FakeDualBlePod(profile = dualProfile, batteryLeftPodPercent = 0.7f)) + .toWidgetKey() shouldNotBe base + dualDevice(ble = FakeDualBlePod(profile = dualProfile, batteryCasePercent = 0.5f)) + .toWidgetKey() shouldNotBe base + dualDevice(ble = FakeDualBlePod(profile = dualProfile, isLeftPodCharging = true)) + .toWidgetKey() shouldNotBe base + } + + @Test + fun `labels and wear state alter key`() { + val dualBase = dualDevice().toWidgetKey() + + dualDevice(label = "Renamed AirPods").toWidgetKey() shouldNotBe dualBase + dualDevice(ble = FakeDualBlePod(profile = dualProfile, isLeftPodInEar = false)) + .toWidgetKey() shouldNotBe dualBase + + val singleBase = singleDevice(ble = FakeSingleBlePod(profile = singleProfile, isBeingWorn = true)) + .toWidgetKey() + singleDevice(ble = FakeSingleBlePod(profile = singleProfile, isBeingWorn = false)) + .toWidgetKey() shouldNotBe singleBase + } + + @Test + fun `aap and anc render state alter key`() { + val connecting = dualDevice( + aap = AapPodState(connectionState = AapPodState.ConnectionState.CONNECTING) + ).toWidgetKey() + val ready = dualDevice( + aap = AapPodState(connectionState = AapPodState.ConnectionState.READY) + ).toWidgetKey() + + ready shouldNotBe connecting + + val ancOn = dualDevice(aap = ancState(current = AapSetting.AncMode.Value.ON)).toWidgetKey() + val ancTransparency = dualDevice(aap = ancState(current = AapSetting.AncMode.Value.TRANSPARENCY)).toWidgetKey() + val ancPending = dualDevice( + aap = ancState( + current = AapSetting.AncMode.Value.ON, + pending = AapSetting.AncMode.Value.TRANSPARENCY, + ) + ).toWidgetKey() + val ancExpandedModes = dualDevice( + aap = ancState( + current = AapSetting.AncMode.Value.ON, + supported = listOf( + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + AapSetting.AncMode.Value.ADAPTIVE, + ), + ) + ).toWidgetKey() + + ancTransparency shouldNotBe ancOn + ancPending shouldNotBe ancOn + ancExpandedModes shouldNotBe ancOn + } + + private fun dualDevice( + label: String? = dualProfile.label, + ble: BlePodSnapshot? = FakeDualBlePod(profile = dualProfile), + aap: AapPodState? = null, + ) = PodDevice( + profileId = dualProfile.id, + label = label, + ble = ble, + aap = aap, + profileAddress = dualProfile.address, + profileModel = dualProfile.model, + ) + + private fun singleDevice( + label: String? = singleProfile.label, + ble: BlePodSnapshot? = FakeSingleBlePod(profile = singleProfile), + aap: AapPodState? = null, + ) = PodDevice( + profileId = singleProfile.id, + label = label, + ble = ble, + aap = aap, + profileAddress = singleProfile.address, + profileModel = singleProfile.model, + ) + + private fun ancState( + current: AapSetting.AncMode.Value, + pending: AapSetting.AncMode.Value? = null, + supported: List = listOf( + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + ), + ) = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + pendingAncMode = pending, + settings = mapOf( + AapSetting.AncMode::class to AapSetting.AncMode(current = current, supported = supported), + AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = true), + AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = 0x0F), + ), + ) + + private data class FakeDualBlePod( + val profile: DeviceProfile, + override val batteryLeftPodPercent: Float? = 0.9f, + override val batteryRightPodPercent: Float? = 1.0f, + override val batteryCasePercent: Float? = 0.8f, + override val isLeftPodCharging: Boolean = false, + override val isRightPodCharging: Boolean = false, + override val isCaseCharging: Boolean = false, + override val isLeftPodInEar: Boolean = true, + override val isRightPodInEar: Boolean = true, + val rssiValue: Int = -60, + override val reliability: Float = 0.8f, + override val seenCounter: Int = 1, + override val seenLastAt: Instant = Instant.parse("2026-04-05T18:00:00Z"), + override val seenFirstAt: Instant = Instant.parse("2026-04-05T17:59:00Z"), + ) : DualBlePodSnapshot, HasChargeDetectionDual, HasEarDetectionDual, HasCase { + override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id() + override val model: PodModel = profile.model + override val scanResult: BleScanResult = BleScanResult( + receivedAt = seenLastAt, + address = profile.address ?: "AA:BB:CC:DD:EE:FF", + rssi = rssiValue, + generatedAtNanos = seenCounter.toLong(), + manufacturerSpecificData = emptyMap(), + ) + override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta { + override val profile: DeviceProfile = this@FakeDualBlePod.profile + } + } + + private data class FakeSingleBlePod( + val profile: DeviceProfile, + override val batteryHeadsetPercent: Float? = 0.7f, + override val isHeadsetBeingCharged: Boolean = false, + override val isBeingWorn: Boolean = true, + val rssiValue: Int = -60, + override val reliability: Float = 0.8f, + override val seenCounter: Int = 1, + override val seenLastAt: Instant = Instant.parse("2026-04-05T18:00:00Z"), + override val seenFirstAt: Instant = Instant.parse("2026-04-05T17:59:00Z"), + ) : SingleBlePodSnapshot, HasChargeDetection, HasEarDetection { + override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id() + override val model: PodModel = profile.model + override val scanResult: BleScanResult = BleScanResult( + receivedAt = seenLastAt, + address = profile.address ?: "11:22:33:44:55:66", + rssi = rssiValue, + generatedAtNanos = seenCounter.toLong(), + manufacturerSpecificData = emptyMap(), + ) + override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta { + override val profile: DeviceProfile = this@FakeSingleBlePod.profile + } + } +} diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt index 4d319bb2..0d426bce 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt @@ -835,10 +835,10 @@ class PodDeviceTest : BaseTest() { device.rssiQuality shouldBe 1.0f } - // --- AAP aggregate ear detection (null per-side) tests --- + // --- AAP aggregate ear detection tests --- @Test - fun `isLeftInEar null when AAP ear detection present but no primaryPod and no BLE`() { + fun `per-side in-ear null when AAP ear detection is mixed but no primaryPod and no BLE`() { val aap = AapPodState( connectionState = AapPodState.ConnectionState.READY, settings = mapOf( @@ -854,6 +854,35 @@ class PodDeviceTest : BaseTest() { device.isRightInEar.shouldBeNull() } + @Test + fun `per-side in-ear resolves when AAP placements match but no primaryPod and no BLE`() { + val aapBothIn = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + ), + ), + ) + val deviceBothIn = PodDevice(profileId = "p1", ble = null, aap = aapBothIn, profileModel = PodModel.AIRPODS_PRO3) + deviceBothIn.isLeftInEar shouldBe true + deviceBothIn.isRightInEar shouldBe true + + val aapBothOut = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + ), + ) + val deviceBothOut = PodDevice(profileId = "p1", ble = null, aap = aapBothOut, profileModel = PodModel.AIRPODS_PRO3) + deviceBothOut.isLeftInEar shouldBe false + deviceBothOut.isRightInEar shouldBe false + } + @Test fun `isBeingWorn works without resolvedPrimaryPod`() { val aapBothIn = AapPodState(