feat(tile): Add ANC Quick Settings tile

This commit is contained in:
darken
2026-05-03 10:58:05 +02:00
committed by Matthias Urhahn
parent 39922df792
commit acc6e92b2d
15 changed files with 1356 additions and 16 deletions
+15 -1
View File
@@ -141,6 +141,20 @@
android:name=".monitor.core.worker.MonitorService"
android:foregroundServiceType="connectedDevice"
android:exported="false" />
<service
android:name=".main.ui.tile.AncTileService"
android:exported="true"
android:icon="@drawable/ic_anc_on"
android:label="@string/tile_anc_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="false" />
</service>
</application>
</manifest>
</manifest>
@@ -1,6 +1,7 @@
package eu.darken.capod.main.ui.components
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.AutoAwesome
@@ -28,3 +29,11 @@ fun AapSetting.AncMode.Value.icon(): ImageVector = when (this) {
AapSetting.AncMode.Value.ADAPTIVE -> Icons.TwoTone.AutoAwesome
}
@DrawableRes
fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
}
@@ -23,6 +23,7 @@ import eu.darken.capod.main.core.MonitorMode
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.tierRank
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
@@ -175,7 +176,7 @@ class OverviewViewModel @Inject constructor(
val profiledDevices: List<PodDevice> by lazy {
devices.filter { it.profileId != null }.sortedWith(
compareBy<PodDevice> { deviceTierRank(it) }
compareBy<PodDevice> { it.tierRank() }
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
)
}
@@ -284,12 +285,5 @@ class OverviewViewModel @Inject constructor(
companion object {
private const val FREE_DEVICE_LIMIT = 1
private val TAG = logTag("Overview", "VM")
/** Connection tier rank for sorting: lower = higher priority. */
internal fun deviceTierRank(device: PodDevice): Int = when {
device.isSystemConnected -> 0
device.isLive -> 1
else -> 2
}
}
}
@@ -0,0 +1,142 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.coroutine.AppScope
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.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Process-scoped trailing-edge debouncer for tile-driven `SetAncMode` commands.
*
* The QS panel collapses after each tap, destroying the [AncTileService] instance.
* If the pending-job state lived on the service, taps across consecutive panel
* sessions wouldn't cancel each other — every tap would fire a separate
* `SetAncMode`, overwhelming the AAP verification loop and triggering
* "Rejected after retry" storms that leave the device unresponsive until the
* app restarts. Keeping the job here, on a `@Singleton`, means a tap in panel
* session B can cancel the deferred send queued by panel session A.
*/
@Singleton
class AncTileSendCoordinator @Inject constructor(
@AppScope private val appScope: CoroutineScope,
private val aapManager: AapConnectionManager,
) {
private val lock = Any()
private val pendingJobs = mutableMapOf<BluetoothAddress, Job>()
private val timeoutJobs = mutableMapOf<BluetoothAddress, Job>()
private val _pendingModes = MutableStateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>>(emptyMap())
val pendingModes: StateFlow<Map<BluetoothAddress, AapSetting.AncMode.Value>> = _pendingModes.asStateFlow()
fun scheduleSetAncMode(
address: BluetoothAddress,
mode: AapSetting.AncMode.Value,
debounce: Duration,
timeout: Duration = 5.seconds,
) {
synchronized(lock) {
val replacing = pendingJobs[address]?.isActive == true
log(TAG, VERBOSE) { "scheduleSetAncMode($mode, addr=$address, debounce=$debounce, replacingPending=$replacing)" }
_pendingModes.value = _pendingModes.value + (address to mode)
pendingJobs.remove(address)?.cancel()
timeoutJobs.remove(address)?.cancel()
pendingJobs[address] = appScope.launch {
delay(debounce)
log(TAG, VERBOSE) { "debounce elapsed, dispatching SetAncMode($mode) to AAP for $address" }
try {
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
log(TAG, VERBOSE) { "sent SetAncMode($mode) to $address" }
} catch (e: CancellationException) {
log(TAG, VERBOSE) { "send for $mode cancelled (newer tap superseded it)" }
throw e
} catch (e: Exception) {
log(TAG, ERROR) { "sendCommand failed: ${e.asLog()}" }
clearPendingTarget(address, mode)
}
}
timeoutJobs[address] = appScope.launch {
delay(timeout)
if (clearPendingTargetFromTimeout(address, mode)) {
log(TAG, VERBOSE) { "pending tile target $mode timed out before device confirmation" }
}
}
}
}
internal fun applyPendingTarget(state: AncTileState): AncTileState {
val active = state as? AncTileState.Active ?: return state
val address = active.deviceAddress ?: return active
val target = pendingModes.value[address] ?: return active
if (target !in active.visible) return active
if (active.isConfirmed(target)) return active
return active.copy(pending = target)
}
internal fun acknowledgeDeviceState(state: AncTileState) {
val active = state as? AncTileState.Active ?: return
val address = active.deviceAddress ?: return
val target = pendingModes.value[address] ?: return
if (target !in active.visible || active.isConfirmed(target)) {
clearPendingTarget(address, target)
}
}
private fun AncTileState.Active.isConfirmed(target: AapSetting.AncMode.Value): Boolean =
pending == target || (current == target && pending == null)
private fun clearPendingTarget(
address: BluetoothAddress,
expectedMode: AapSetting.AncMode.Value,
): Boolean = synchronized(lock) {
val current = _pendingModes.value[address] ?: return@synchronized false
if (current != expectedMode) return@synchronized false
_pendingModes.value = _pendingModes.value - address
pendingJobs.remove(address)?.cancel()
timeoutJobs.remove(address)?.cancel()
true
}
private fun clearPendingTargetFromTimeout(
address: BluetoothAddress,
expectedMode: AapSetting.AncMode.Value,
): Boolean = synchronized(lock) {
val current = _pendingModes.value[address] ?: return@synchronized false
if (current != expectedMode) return@synchronized false
_pendingModes.value = _pendingModes.value - address
pendingJobs.remove(address)?.cancel()
timeoutJobs.remove(address)
true
}
companion object {
private val TAG = logTag("Tile", "Anc", "Coord")
}
}
@@ -0,0 +1,227 @@
package eu.darken.capod.main.ui.tile
import android.app.PendingIntent
import android.content.Intent
import android.graphics.drawable.Icon
import android.os.SystemClock
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.coroutine.DispatcherProvider
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.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.throttleLatest
import eu.darken.capod.common.hasApiLevel
import eu.darken.capod.main.ui.MainActivity
import eu.darken.capod.main.ui.components.iconDrawableRes
import eu.darken.capod.main.ui.components.shortLabel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
/**
* Quick Settings tile that cycles ANC modes for the user-perceived primary AirPods.
* Mirrors [eu.darken.capod.main.ui.widget.AncGlanceWidget] but renders into the system
* QS panel instead of a home-screen widget.
*/
@AndroidEntryPoint
class AncTileService : TileService() {
@Inject lateinit var dispatcherProvider: DispatcherProvider
@Inject lateinit var sendCoordinator: AncTileSendCoordinator
@Inject lateinit var stateStore: AncTileStateStore
private var listenScope: CoroutineScope? = null
private val instanceId = Integer.toHexString(System.identityHashCode(this))
override fun onCreate() {
super.onCreate()
log(TAG, VERBOSE) { "onCreate(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
}
override fun onTileAdded() {
log(TAG, VERBOSE) { "onTileAdded(instance=$instanceId)" }
// Render a static placeholder so we never block SystemUI's bind path on a
// first-emission DataStore/Billing read. onStartListening will fill in real state.
renderTile(AncTileState.Connecting)
}
override fun onStartListening() {
log(TAG, VERBOSE) { "onStartListening(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()})" }
listenScope?.cancel()
val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.Default)
listenScope = scope
scope.launch {
stateStore.state.throttleLatest(250).collect { state ->
log(TAG, VERBOSE) { "collector: state=$state" }
withContext(dispatcherProvider.Main) { renderTile(state) }
}
}
}
override fun onStopListening() {
log(TAG, VERBOSE) { "onStopListening(instance=$instanceId)" }
listenScope?.cancel()
listenScope = null
}
override fun onDestroy() {
val now = SystemClock.elapsedRealtime()
log(TAG, VERBOSE) { "onDestroy(instance=$instanceId)" }
lastDestroyAt = now
listenScope?.cancel()
super.onDestroy()
}
override fun onClick() {
log(TAG, VERBOSE) { "onClick(instance=$instanceId, sinceDestroy=${elapsedSinceLastDestroy()}) received tap" }
val state = stateStore.currentState()
log(TAG, VERBOSE) {
"onClick: resolved from state store=$state"
}
dispatchClick(state)
}
private fun dispatchClick(state: AncTileState) {
log(TAG, VERBOSE) { "dispatchClick($state)" }
when (state) {
AncTileState.NotPro,
AncTileState.PermissionRequired -> openMainActivityWithUpgrade()
is AncTileState.Active -> sendNextMode(state)
AncTileState.BluetoothOff,
AncTileState.NoDevice,
AncTileState.NoAncSupport,
AncTileState.NotConnected,
AncTileState.Connecting -> {
// Tile state is STATE_UNAVAILABLE; system shouldn't deliver clicks here,
// but defensively no-op so we don't crash on unexpected delivery.
}
}
}
private fun sendNextMode(state: AncTileState.Active) {
val nextMode = pickNextMode(state.visible, state.current, state.pending)
log(TAG, VERBOSE) {
"sendNextMode: visible=${state.visible} current=${state.current} pending=${state.pending} -> next=$nextMode"
}
if (nextMode == null || nextMode == (state.pending ?: state.current)) {
log(TAG, VERBOSE) { "sendNextMode: no advance possible (visible=${state.visible})" }
return
}
val address: BluetoothAddress = state.deviceAddress ?: run {
log(TAG, WARN) { "sendNextMode: state has no device address" }
return
}
sendCoordinator.scheduleSetAncMode(address, nextMode, 1.seconds)
val optimisticState = state.copy(pending = nextMode)
renderTile(optimisticState)
}
private fun openMainActivityWithUpgrade() {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(MainActivity.EXTRA_NAVIGATE_TO_UPGRADE, true)
}
if (hasApiLevel(34)) {
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
startActivityAndCollapse(intent)
}
}
private fun renderTile(state: AncTileState) {
val tile = qsTile ?: run {
log(TAG, VERBOSE) { "renderTile: skipped, qsTile is null (state=$state)" }
return
}
log(TAG, VERBOSE) { "renderTile: $state" }
val baseLabel = getString(R.string.tile_anc_label)
val (subtitle, iconRes, tileState) = when (state) {
AncTileState.NotPro -> Triple(
getString(R.string.common_upgrade_required_label),
R.drawable.ic_anc_off,
Tile.STATE_INACTIVE,
)
AncTileState.PermissionRequired -> Triple(
getString(R.string.tile_anc_subtitle_permission_required),
R.drawable.ic_anc_off,
Tile.STATE_INACTIVE,
)
AncTileState.BluetoothOff -> Triple(
getString(R.string.tile_anc_subtitle_bluetooth_off),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.NoDevice -> Triple(
getString(R.string.tile_anc_subtitle_no_device),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.NoAncSupport -> Triple(
getString(R.string.tile_anc_subtitle_no_anc_support),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.NotConnected -> Triple(
getString(R.string.anc_widget_aap_not_connected_label),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
AncTileState.Connecting -> Triple(
getString(R.string.anc_widget_aap_connecting_label),
R.drawable.ic_anc_off,
Tile.STATE_UNAVAILABLE,
)
is AncTileState.Active -> {
val displayMode = state.pending?.takeIf { it in state.visible } ?: state.current
Triple(
displayMode.shortLabel(this),
displayMode.iconDrawableRes(),
Tile.STATE_ACTIVE,
)
}
}
tile.icon = Icon.createWithResource(this, iconRes)
tile.state = tileState
if (hasApiLevel(29)) {
tile.label = baseLabel
tile.subtitle = subtitle
} else {
// Pre-API 29 tiles can't show a subtitle; fold it into the label.
tile.label = "$baseLabel · $subtitle"
}
if (hasApiLevel(30)) {
tile.stateDescription = subtitle
}
tile.updateTile()
}
companion object {
private val TAG = logTag("Tile", "Anc")
@Volatile private var lastDestroyAt: Long? = null
private fun elapsedSinceLastDestroy(): String {
val destroyedAt = lastDestroyAt ?: return "n/a"
return "${SystemClock.elapsedRealtime() - destroyedAt}ms"
}
}
}
@@ -0,0 +1,88 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.visibleAncModes
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
/**
* Pure data → data mapper. Decides which [AncTileState] to render given a snapshot
* of inputs. Mirrors [AncWidgetRenderStateMapper]'s precedence so the two surfaces
* stay in sync, while remaining unit-testable without Android resources.
*
* Note: cached/offline-tier devices land in [AncTileState.NotConnected] (not
* [AncTileState.Connecting]) — without an active AAP session, "Connecting…" would
* be misleading because nothing is in progress.
*/
object AncTileStateMapper {
fun map(
device: PodDevice?,
isPro: Boolean,
isBluetoothEnabled: Boolean,
missingPermissions: Set<Permission>,
): AncTileState {
if (!isPro) return AncTileState.NotPro
if (missingPermissions.any { it.isTileBlocking }) return AncTileState.PermissionRequired
if (!isBluetoothEnabled) return AncTileState.BluetoothOff
if (device == null) return AncTileState.NoDevice
if (!device.hasAncControl) return AncTileState.NoAncSupport
if (!device.isAapConnected) return AncTileState.NotConnected
if (!device.isAapReady) return AncTileState.Connecting
val ancMode = device.ancMode ?: return AncTileState.Connecting
val visible = device.visibleAncModes
if (visible.isEmpty()) return AncTileState.Connecting
return AncTileState.Active(
current = ancMode.current,
pending = device.pendingAncMode,
visible = visible,
deviceLabel = device.label,
deviceAddress = device.address,
)
}
}
/**
* A permission whose absence prevents the tile from working. Scan-blocking permissions
* gate BLE; [Permission.BLUETOOTH_CONNECT] gates the AAP L2CAP socket — without either,
* the tile cannot do useful work.
*/
private val Permission.isTileBlocking: Boolean
get() = isScanBlocking || this == Permission.BLUETOOTH_CONNECT
sealed interface AncTileState {
data object NotPro : AncTileState
data object PermissionRequired : AncTileState
data object BluetoothOff : AncTileState
data object NoDevice : AncTileState
data object NoAncSupport : AncTileState
data object NotConnected : AncTileState
data object Connecting : AncTileState
data class Active(
val current: AapSetting.AncMode.Value,
val pending: AapSetting.AncMode.Value?,
val visible: List<AapSetting.AncMode.Value>,
val deviceLabel: String?,
val deviceAddress: BluetoothAddress?,
) : AncTileState
}
/**
* Cycles to the next mode in [visible]. Anchors on [pending] when it's still in
* [visible] so rapid taps walk forward through the list rather than oscillate against
* the device-echoed [current]. A [pending] that has been filtered out (e.g. user toggled
* Allow Off mid-cycle) falls through to [current].
*/
internal fun pickNextMode(
visible: List<AapSetting.AncMode.Value>,
current: AapSetting.AncMode.Value?,
pending: AapSetting.AncMode.Value?,
): AapSetting.AncMode.Value? {
if (visible.isEmpty()) return null
val anchor = pending?.takeIf { it in visible } ?: current ?: return visible.first()
val idx = visible.indexOf(anchor)
return if (idx < 0) visible.first() else visible[(idx + 1) % visible.size]
}
@@ -0,0 +1,85 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.flow.combine
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.primaryByTier
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.combine as combineFlows
/**
* Process-scoped state holder for the ANC QS tile.
*
* TileService instances are short-lived and can disappear between taps. Keeping
* the latest state here lets a recreated service resolve clicks synchronously
* while the app process is alive. A cold or hydrating process intentionally
* reports [AncTileState.Connecting] so the tile UI is honest about not being
* ready for input yet.
*/
@Singleton
class AncTileStateStore @Inject constructor(
@AppScope private val appScope: CoroutineScope,
deviceMonitor: DeviceMonitor,
profilesRepo: DeviceProfilesRepo,
upgradeRepo: UpgradeRepo,
bluetoothManager: BluetoothManager2,
permissionTool: PermissionTool,
private val sendCoordinator: AncTileSendCoordinator,
) {
private val rawState: StateFlow<AncTileState> = combine(
deviceMonitor.devices,
profilesRepo.profiles,
upgradeRepo.upgradeInfo.map { it.isPro },
bluetoothManager.isBluetoothEnabled,
permissionTool.missingPermissions,
) { devices, profiles, isPro, isBluetoothEnabled, missingPermissions ->
val profileOrder = profiles.mapIndexed { idx, p -> p.id to idx }.toMap()
val device = devices.primaryByTier(profileOrder)
AncTileStateMapper.map(
device = device,
isPro = isPro,
isBluetoothEnabled = isBluetoothEnabled,
missingPermissions = missingPermissions,
)
}
.distinctUntilChanged()
.stateIn(
scope = appScope,
started = SharingStarted.Eagerly,
initialValue = AncTileState.Connecting,
)
init {
appScope.launch {
rawState.collect { state -> sendCoordinator.acknowledgeDeviceState(state) }
}
}
val state: StateFlow<AncTileState> = combineFlows(
rawState,
sendCoordinator.pendingModes,
) { rawState, _ ->
sendCoordinator.applyPendingTarget(rawState)
}
.distinctUntilChanged()
.stateIn(
scope = appScope,
started = SharingStarted.Eagerly,
initialValue = AncTileState.Connecting,
)
fun currentState(): AncTileState = sendCoordinator.applyPendingTarget(rawState.value)
}
@@ -2,6 +2,7 @@ package eu.darken.capod.main.ui.widget
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.main.ui.components.iconDrawableRes
import eu.darken.capod.main.ui.components.shortLabel
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.visibleAncModes
@@ -121,10 +122,3 @@ object AncWidgetRenderStateMapper {
}
}
}
private fun AapSetting.AncMode.Value.iconDrawableRes(): Int = when (this) {
AapSetting.AncMode.Value.OFF -> R.drawable.ic_anc_off
AapSetting.AncMode.Value.ON -> R.drawable.ic_anc_on
AapSetting.AncMode.Value.TRANSPARENCY -> R.drawable.ic_anc_transparency
AapSetting.AncMode.Value.ADAPTIVE -> R.drawable.ic_anc_adaptive
}
@@ -0,0 +1,26 @@
package eu.darken.capod.monitor.core
/**
* Connection tier rank used to sort devices by user-perceived "primary":
* lower rank = higher priority. System-connected devices come first, then
* any live device (BLE or AAP), then profiled-but-offline.
*
* Distinct from [DeviceMonitor.primaryDevice] which is intentionally
* non-tier-ranked for reaction flows that want "any profiled device".
*/
fun PodDevice.tierRank(): Int = when {
isSystemConnected -> 0
isLive -> 1
else -> 2
}
/**
* Picks the user-perceived primary profiled device: lowest [tierRank], with
* the user's profile-list order as the tiebreaker.
*/
fun List<PodDevice>.primaryByTier(profileOrder: Map<String, Int>): PodDevice? =
filter { it.profileId != null }
.minWithOrNull(
compareBy<PodDevice> { it.tierRank() }
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
)
+5
View File
@@ -158,6 +158,11 @@
<string name="anc_widget_no_anc_support_label">This device does not support noise control</string>
<string name="anc_widget_aap_not_connected_label">Not connected</string>
<string name="anc_widget_aap_connecting_label">Connecting…</string>
<string name="tile_anc_label">Noise Control</string>
<string name="tile_anc_subtitle_permission_required">Permission required</string>
<string name="tile_anc_subtitle_no_device">No device</string>
<string name="tile_anc_subtitle_no_anc_support">No noise control</string>
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth off</string>
<string name="widget_config_screen_title">Widget Configuration</string>
<string name="widget_configuration_title">Select Device</string>
<string name="widget_configuration_description">Choose which device profile this widget should display.</string>
@@ -0,0 +1,62 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class AncTileCycleTest : BaseTest() {
private val off = AapSetting.AncMode.Value.OFF
private val on = AapSetting.AncMode.Value.ON
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
private val ad = AapSetting.AncMode.Value.ADAPTIVE
@Test
fun `empty visible list returns null`() {
pickNextMode(visible = emptyList(), current = on, pending = null) shouldBe null
}
@Test
fun `single visible mode returns same mode`() {
pickNextMode(visible = listOf(on), current = on, pending = null) shouldBe on
}
@Test
fun `current null and non-empty visible returns first`() {
pickNextMode(visible = listOf(off, tx, ad), current = null, pending = null) shouldBe off
}
@Test
fun `wraps around at end of list`() {
pickNextMode(visible = listOf(off, tx, ad), current = ad, pending = null) shouldBe off
}
@Test
fun `advances through middle of list`() {
pickNextMode(visible = listOf(off, tx, ad), current = tx, pending = null) shouldBe ad
}
@Test
fun `pending wins over current when pending is in visible`() {
// current=off, pending=tx → next is ad (anchor on pending so rapid taps walk forward)
pickNextMode(visible = listOf(off, tx, ad), current = off, pending = tx) shouldBe ad
}
@Test
fun `pending falls through to current when pending was filtered out`() {
// pending=off but visible no longer contains off → fall through to current=tx → next is ad
pickNextMode(visible = listOf(tx, ad), current = tx, pending = off) shouldBe ad
}
@Test
fun `current not in visible falls through to first`() {
// current=on but visible doesn't contain it → start at first (off)
pickNextMode(visible = listOf(off, tx, ad), current = on, pending = null) shouldBe off
}
@Test
fun `pending not visible and current null falls back to first`() {
pickNextMode(visible = listOf(off, tx, ad), current = null, pending = on) shouldBe off
}
}
@@ -0,0 +1,194 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import kotlin.time.Duration.Companion.seconds
class AncTileSendCoordinatorTest : BaseTest() {
private val address = "00:11:22:33:44:55"
private val off = AapSetting.AncMode.Value.OFF
private val on = AapSetting.AncMode.Value.ON
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
private val ad = AapSetting.AncMode.Value.ADAPTIVE
private val visible = listOf(off, on, tx, ad)
@Test
fun `pending target is visible immediately and survives service restart state`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
val rendered = coordinator.applyPendingTarget(active(current = off, pending = null))
rendered.shouldBeInstanceOf<AncTileState.Active>()
rendered.pending shouldBe tx
pickNextMode(rendered.visible, rendered.current, rendered.pending) shouldBe ad
}
@Test
fun `replacing pending send dispatches only latest target`() = runTest {
val aapManager = mockk<AapConnectionManager>(relaxed = true)
val coordinator = coordinator(aapManager)
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
coordinator.pendingModes.value[address] shouldBe tx
advanceTimeBy(999)
runCurrent()
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
coordinator.scheduleSetAncMode(address, ad, debounce = 1.seconds)
coordinator.pendingModes.value[address] shouldBe ad
advanceTimeBy(999)
runCurrent()
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
advanceTimeBy(1)
runCurrent()
coVerify(exactly = 0) { aapManager.sendCommand(address, AapCommand.SetAncMode(tx)) }
coVerify(exactly = 1) { aapManager.sendCommand(address, AapCommand.SetAncMode(ad)) }
}
@Test
fun `applying device pending confirmation is pure and keeps rendered pending mode`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
val rendered = coordinator.applyPendingTarget(active(current = off, pending = tx))
rendered.shouldBeInstanceOf<AncTileState.Active>()
rendered.pending shouldBe tx
coordinator.pendingModes.value[address] shouldBe tx
}
@Test
fun `acknowledging device pending confirmation clears process target`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
coordinator.acknowledgeDeviceState(active(current = off, pending = tx))
coordinator.pendingModes.value[address] shouldBe null
}
@Test
fun `applying device current confirmation is pure`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
val rendered = coordinator.applyPendingTarget(active(current = tx, pending = null))
rendered.shouldBeInstanceOf<AncTileState.Active>()
rendered.pending shouldBe null
coordinator.pendingModes.value[address] shouldBe tx
}
@Test
fun `acknowledging device current confirmation clears process target`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
coordinator.acknowledgeDeviceState(active(current = tx, pending = null))
coordinator.pendingModes.value[address] shouldBe null
}
@Test
fun `target matching current is kept while device reports different pending mode`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
val rendered = coordinator.applyPendingTarget(active(current = tx, pending = off))
rendered.shouldBeInstanceOf<AncTileState.Active>()
rendered.pending shouldBe tx
coordinator.pendingModes.value[address] shouldBe tx
}
@Test
fun `applying target filtered out of visible modes is pure`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, off, debounce = 1.seconds)
val rendered = coordinator.applyPendingTarget(active(current = on, pending = null, visible = listOf(on, tx, ad)))
rendered.shouldBeInstanceOf<AncTileState.Active>()
rendered.pending shouldBe null
coordinator.pendingModes.value[address] shouldBe off
}
@Test
fun `acknowledging target filtered out of visible modes clears process target`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, off, debounce = 1.seconds)
coordinator.acknowledgeDeviceState(active(current = on, pending = null, visible = listOf(on, tx, ad)))
coordinator.pendingModes.value[address] shouldBe null
}
@Test
fun `pending target clears after timeout without confirmation`() = runTest {
val coordinator = coordinator()
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds, timeout = 5.seconds)
coordinator.pendingModes.value[address] shouldBe tx
advanceTimeBy(5_000)
runCurrent()
coordinator.pendingModes.value[address] shouldBe null
}
@Test
fun `send failure clears pending target`() = runTest {
val aapManager = mockk<AapConnectionManager>(relaxed = true)
coEvery {
aapManager.sendCommand(address, AapCommand.SetAncMode(tx))
} throws IllegalStateException("not connected")
val coordinator = coordinator(aapManager)
coordinator.scheduleSetAncMode(address, tx, debounce = 1.seconds)
coordinator.pendingModes.value[address] shouldBe tx
advanceTimeBy(1_000)
runCurrent()
coordinator.pendingModes.value[address] shouldBe null
}
private fun TestScope.coordinator(
aapManager: AapConnectionManager = mockk(relaxed = true),
): AncTileSendCoordinator = AncTileSendCoordinator(
appScope = backgroundScope,
aapManager = aapManager,
)
private fun active(
current: AapSetting.AncMode.Value,
pending: AapSetting.AncMode.Value?,
visible: List<AapSetting.AncMode.Value> = this.visible,
) = AncTileState.Active(
current = current,
pending = pending,
visible = visible,
deviceLabel = "Pods",
deviceAddress = address,
)
}
@@ -0,0 +1,218 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.permissions.Permission
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 io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class AncTileStateMapperTest : BaseTest() {
private val noPermissions = emptySet<Permission>()
private val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
)
private fun activeDevice(
currentMode: AapSetting.AncMode.Value = AapSetting.AncMode.Value.ON,
pendingMode: AapSetting.AncMode.Value? = null,
connectionState: AapPodState.ConnectionState = AapPodState.ConnectionState.READY,
model: PodModel = PodModel.AIRPODS_PRO,
): PodDevice {
val ancSetting = AapSetting.AncMode(current = currentMode, supported = supportedModes)
return PodDevice(
profileId = "p1",
ble = null,
aap = AapPodState(
connectionState = connectionState,
settings = mapOf(AapSetting.AncMode::class to ancSetting),
pendingAncMode = pendingMode,
),
profileModel = model,
)
}
@Test
fun `not pro returns NotPro regardless of other state`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = false,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.NotPro
}
@Test
fun `missing scan permission returns PermissionRequired`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = setOf(Permission.BLUETOOTH_SCAN),
) shouldBe AncTileState.PermissionRequired
}
@Test
fun `missing BLUETOOTH_CONNECT returns PermissionRequired`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = setOf(Permission.BLUETOOTH_CONNECT),
) shouldBe AncTileState.PermissionRequired
}
@Test
fun `missing non-blocking permission does not flip state`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = setOf(Permission.POST_NOTIFICATIONS),
).shouldBeInstanceOf<AncTileState.Active>()
}
@Test
fun `bluetooth disabled returns BluetoothOff`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = true,
isBluetoothEnabled = false,
missingPermissions = noPermissions,
) shouldBe AncTileState.BluetoothOff
}
@Test
fun `null device returns NoDevice`() {
AncTileStateMapper.map(
device = null,
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.NoDevice
}
@Test
fun `device without ANC support returns NoAncSupport`() {
val device = PodDevice(
profileId = "p1",
ble = null,
aap = null,
profileModel = PodModel.AIRPODS_GEN1,
)
AncTileStateMapper.map(
device = device,
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.NoAncSupport
}
@Test
fun `cached device with no AAP session returns NotConnected`() {
// ANC-capable model but aap == null → user sees "Disconnected", not "Connecting forever".
val device = PodDevice(
profileId = "p1",
ble = null,
aap = null,
profileModel = PodModel.AIRPODS_PRO,
)
AncTileStateMapper.map(
device = device,
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.NotConnected
}
@Test
fun `aap connected but not ready returns Connecting`() {
AncTileStateMapper.map(
device = activeDevice(connectionState = AapPodState.ConnectionState.HANDSHAKING),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.Connecting
}
@Test
fun `aap ready without AncMode setting returns Connecting`() {
val device = PodDevice(
profileId = "p1",
ble = null,
aap = AapPodState(connectionState = AapPodState.ConnectionState.READY),
profileModel = PodModel.AIRPODS_PRO,
)
AncTileStateMapper.map(
device = device,
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
) shouldBe AncTileState.Connecting
}
@Test
fun `fully ready device returns Active with current and visible modes`() {
val state = AncTileStateMapper.map(
device = activeDevice(currentMode = AapSetting.AncMode.Value.TRANSPARENCY),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
)
state.shouldBeInstanceOf<AncTileState.Active>()
state.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
state.visible shouldBe supportedModes
}
@Test
fun `pending mode is propagated to Active`() {
val state = AncTileStateMapper.map(
device = activeDevice(
currentMode = AapSetting.AncMode.Value.OFF,
pendingMode = AapSetting.AncMode.Value.TRANSPARENCY,
),
isPro = true,
isBluetoothEnabled = true,
missingPermissions = noPermissions,
)
state.shouldBeInstanceOf<AncTileState.Active>()
state.pending shouldBe AapSetting.AncMode.Value.TRANSPARENCY
}
@Test
fun `precedence pro gating wins over bluetooth off`() {
AncTileStateMapper.map(
device = activeDevice(),
isPro = false,
isBluetoothEnabled = false,
missingPermissions = noPermissions,
) shouldBe AncTileState.NotPro
}
@Test
fun `precedence bluetooth off wins over no device`() {
AncTileStateMapper.map(
device = null,
isPro = true,
isBluetoothEnabled = false,
missingPermissions = noPermissions,
) shouldBe AncTileState.BluetoothOff
}
@Test
fun `precedence permission required wins over bluetooth off`() {
AncTileStateMapper.map(
device = null,
isPro = true,
isBluetoothEnabled = false,
missingPermissions = setOf(Permission.BLUETOOTH_SCAN),
) shouldBe AncTileState.PermissionRequired
}
}
@@ -0,0 +1,198 @@
package eu.darken.capod.main.ui.tile
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
import kotlin.time.Duration.Companion.seconds
class AncTileStateStoreTest : BaseTest() {
private val address = "00:11:22:33:44:55"
private val off = AapSetting.AncMode.Value.OFF
private val on = AapSetting.AncMode.Value.ON
private val tx = AapSetting.AncMode.Value.TRANSPARENCY
private val ad = AapSetting.AncMode.Value.ADAPTIVE
private val visible = listOf(off, on, tx, ad)
@Test
fun `current state survives service listener gap`() = runTest {
val devices = MutableStateFlow(listOf(activeDevice(currentMode = off)))
val store = store(devices = devices)
val listener = collectState(store)
runCurrent()
val activeBeforeGap = store.currentState()
activeBeforeGap.shouldBeInstanceOf<AncTileState.Active>()
activeBeforeGap.current shouldBe off
listener.cancel()
runCurrent()
val activeAfterGap = store.currentState()
activeAfterGap.shouldBeInstanceOf<AncTileState.Active>()
activeAfterGap.current shouldBe off
}
@Test
fun `current state warms without service listener`() = runTest {
val store = store(devices = MutableStateFlow(listOf(activeDevice(currentMode = off))))
runCurrent()
val current = store.currentState()
current.shouldBeInstanceOf<AncTileState.Active>()
current.current shouldBe off
}
@Test
fun `current state reports connecting while live device is not ready`() = runTest {
val devices = MutableStateFlow(listOf(connectingDevice()))
val store = store(devices = devices)
runCurrent()
store.currentState() shouldBe AncTileState.Connecting
}
@Test
fun `current state overlays coordinator target before collected state updates`() = runTest {
val coordinator = coordinator()
val store = store(
devices = MutableStateFlow(listOf(activeDevice(currentMode = off))),
sendCoordinator = coordinator,
)
val listener = collectState(store)
runCurrent()
coordinator.scheduleSetAncMode(address, on, debounce = 1.seconds)
val current = store.currentState()
current.shouldBeInstanceOf<AncTileState.Active>()
current.pending shouldBe on
listener.cancel()
}
@Test
fun `device confirmation clears coordinator target through state store`() = runTest {
val devices = MutableStateFlow(listOf(activeDevice(currentMode = off)))
val coordinator = coordinator()
val store = store(
devices = devices,
sendCoordinator = coordinator,
)
runCurrent()
coordinator.scheduleSetAncMode(address, on, debounce = 1.seconds)
coordinator.pendingModes.value[address] shouldBe on
devices.value = listOf(activeDevice(currentMode = on))
runCurrent()
coordinator.pendingModes.value[address] shouldBe null
val current = store.currentState()
current.shouldBeInstanceOf<AncTileState.Active>()
current.current shouldBe on
current.pending shouldBe null
}
private fun TestScope.collectState(store: AncTileStateStore): Job = launch {
store.state.collect {}
}
private fun TestScope.store(
devices: MutableStateFlow<List<PodDevice>>,
profiles: MutableStateFlow<List<DeviceProfile>> = MutableStateFlow(emptyList()),
isPro: MutableStateFlow<Boolean> = MutableStateFlow(true),
bluetoothEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
missingPermissions: MutableStateFlow<Set<Permission>> = MutableStateFlow(emptySet()),
sendCoordinator: AncTileSendCoordinator = coordinator(),
): AncTileStateStore {
val deviceMonitor = mockk<DeviceMonitor> {
every { this@mockk.devices } returns devices
}
val profilesRepo = mockk<DeviceProfilesRepo> {
every { this@mockk.profiles } returns profiles
}
val upgradeRepo = mockk<UpgradeRepo> {
every { upgradeInfo } returns MutableStateFlow(upgradeInfo(isPro.value))
}
val bluetoothManager = mockk<BluetoothManager2> {
every { isBluetoothEnabled } returns bluetoothEnabled
}
val permissionTool = mockk<PermissionTool> {
every { this@mockk.missingPermissions } returns missingPermissions
}
return AncTileStateStore(
appScope = backgroundScope,
deviceMonitor = deviceMonitor,
profilesRepo = profilesRepo,
upgradeRepo = upgradeRepo,
bluetoothManager = bluetoothManager,
permissionTool = permissionTool,
sendCoordinator = sendCoordinator,
)
}
private fun TestScope.coordinator(
aapManager: AapConnectionManager = mockk(relaxed = true),
): AncTileSendCoordinator = AncTileSendCoordinator(
appScope = backgroundScope,
aapManager = aapManager,
)
private fun activeDevice(
currentMode: AapSetting.AncMode.Value,
pendingMode: AapSetting.AncMode.Value? = null,
): PodDevice {
val ancSetting = AapSetting.AncMode(current = currentMode, supported = visible)
return PodDevice(
profileId = "p1",
ble = null,
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(AapSetting.AncMode::class to ancSetting),
pendingAncMode = pendingMode,
),
profileAddress = address,
profileModel = PodModel.AIRPODS_PRO,
)
}
private fun connectingDevice(): PodDevice = PodDevice(
profileId = "p1",
ble = null,
aap = AapPodState(connectionState = AapPodState.ConnectionState.HANDSHAKING),
profileAddress = address,
profileModel = PodModel.AIRPODS_PRO,
)
private fun upgradeInfo(isPro: Boolean) = object : UpgradeRepo.Info {
override val type: UpgradeRepo.Type = UpgradeRepo.Type.FOSS
override val isPro: Boolean = isPro
override val upgradedAt: Instant? = null
override val error: Throwable? = null
}
}
@@ -0,0 +1,84 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class PodDeviceTierTest : BaseTest() {
private fun device(
profileId: String?,
isSystemConnected: Boolean = false,
isLive: Boolean = false,
): PodDevice {
// isLive is derived from ble != null || aap != null. Use a minimal AapPodState so we
// don't need to fabricate a BLE snapshot for the live case.
val aap = if (isLive) AapPodState() else null
return PodDevice(
profileId = profileId,
ble = null,
aap = aap,
profileModel = PodModel.AIRPODS_PRO,
isSystemConnected = isSystemConnected,
)
}
@Test
fun `system connected ranks above live which ranks above offline`() {
device(profileId = "a", isSystemConnected = true).tierRank() shouldBe 0
device(profileId = "a", isLive = true).tierRank() shouldBe 1
device(profileId = "a").tierRank() shouldBe 2
}
@Test
fun `system connected wins regardless of live`() {
// isSystemConnected true takes precedence even if also live
device(profileId = "a", isSystemConnected = true, isLive = true).tierRank() shouldBe 0
}
@Test
fun `primaryByTier picks system-connected device first`() {
val live = device(profileId = "a", isLive = true)
val systemConnected = device(profileId = "b", isSystemConnected = true)
val devices = listOf(live, systemConnected)
devices.primaryByTier(profileOrder = mapOf("a" to 0, "b" to 1)) shouldBe systemConnected
}
@Test
fun `primaryByTier uses profile order as tiebreaker within a tier`() {
val first = device(profileId = "a", isLive = true)
val second = device(profileId = "b", isLive = true)
val devices = listOf(second, first)
devices.primaryByTier(profileOrder = mapOf("a" to 0, "b" to 1)) shouldBe first
}
@Test
fun `primaryByTier ignores profileless devices`() {
val anonymous = device(profileId = null, isSystemConnected = true)
val profiled = device(profileId = "a", isLive = true)
val devices = listOf(anonymous, profiled)
devices.primaryByTier(profileOrder = mapOf("a" to 0)) shouldBe profiled
}
@Test
fun `primaryByTier returns null when no profiled devices`() {
val devices = listOf(device(profileId = null, isLive = true))
devices.primaryByTier(profileOrder = emptyMap()) shouldBe null
}
@Test
fun `primaryByTier returns null on empty list`() {
emptyList<PodDevice>().primaryByTier(profileOrder = emptyMap()) shouldBe null
}
@Test
fun `primaryByTier handles missing profile order with stable fallback`() {
// Profile id not present in map → falls back to Int.MAX_VALUE (last)
val withOrder = device(profileId = "a", isLive = true)
val withoutOrder = device(profileId = "z", isLive = true)
val devices = listOf(withoutOrder, withOrder)
devices.primaryByTier(profileOrder = mapOf("a" to 0)) shouldBe withOrder
}
}