fix(widget): Re-render widgets reactively after initial session start

Glance only calls provideGlance() once per widget session; subsequent update() calls recompose the existing composition without re-running provideGlance. The previous capture-once approach left widgets frozen at initial state because the composition had no reactive State to read.

Subscribe to a widgetDeviceFlow(profileId) inside provideContent that pre-filters by WidgetDeviceKey, so the composition recomposes on visible state changes without firing on every BLE advertisement. Replace updateAll() with explicit per-GlanceId update() calls in WidgetManager, with a platform-id fallback when Glance returns no IDs.
This commit is contained in:
darken
2026-04-30 16:44:57 +02:00
committed by Matthias Urhahn
parent c8f5ceda34
commit b952aff38d
9 changed files with 449 additions and 72 deletions
@@ -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),
@@ -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 {
@@ -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),
@@ -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<PodDevice?> = devices
.map { devices -> devices.firstOrNull { it.profileId == profileId } }
.distinctUntilChangedBy { it?.toWidgetKey() }
@@ -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<AapSetting.AncMode.Value>,
)
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,
)
@@ -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 <T : GlanceAppWidget> refreshWidget(
name: String,
widget: T,
providerClass: Class<T>,
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<GlanceId>.toAppWidgetIds(manager: GlanceAppWidgetManager): List<Int> =
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")
}
}
@@ -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
}
@@ -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<AapSetting.AncMode.Value> = 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
}
}
}
@@ -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(