mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user