mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user