diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 63eea704..385a54d7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -141,6 +141,20 @@ android:name=".monitor.core.worker.MonitorService" android:foregroundServiceType="connectedDevice" android:exported="false" /> + + + + + + + - \ No newline at end of file + diff --git a/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt b/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt index 50fdbbdb..40d67b02 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/components/AncModeUi.kt @@ -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 +} + diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index f87e8e7f..e7076d02 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -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 by lazy { devices.filter { it.profileId != null }.sortedWith( - compareBy { deviceTierRank(it) } + compareBy { 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 - } } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileSendCoordinator.kt b/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileSendCoordinator.kt new file mode 100644 index 00000000..15921891 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileSendCoordinator.kt @@ -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() + private val timeoutJobs = mutableMapOf() + + private val _pendingModes = MutableStateFlow>(emptyMap()) + val pendingModes: StateFlow> = _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") + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileService.kt b/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileService.kt new file mode 100644 index 00000000..cc6690aa --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileService.kt @@ -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" + } + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileStateMapper.kt b/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileStateMapper.kt new file mode 100644 index 00000000..2e6618dc --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileStateMapper.kt @@ -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, + ): 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, + 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, + 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] +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileStateStore.kt b/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileStateStore.kt new file mode 100644 index 00000000..35743d84 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/tile/AncTileStateStore.kt @@ -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 = 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 = 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) +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderStateMapper.kt b/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderStateMapper.kt index 4359a578..7acbc14d 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderStateMapper.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/widget/AncWidgetRenderStateMapper.kt @@ -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 -} diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceTier.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceTier.kt new file mode 100644 index 00000000..4736040c --- /dev/null +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDeviceTier.kt @@ -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.primaryByTier(profileOrder: Map): PodDevice? = + filter { it.profileId != null } + .minWithOrNull( + compareBy { it.tierRank() } + .thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE } + ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ddcebaff..169769a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -158,6 +158,11 @@ This device does not support noise control Not connected Connecting… + Noise Control + Permission required + No device + No noise control + Bluetooth off Widget Configuration Select Device Choose which device profile this widget should display. diff --git a/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileCycleTest.kt b/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileCycleTest.kt new file mode 100644 index 00000000..8273dafd --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileCycleTest.kt @@ -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 + } +} diff --git a/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileSendCoordinatorTest.kt b/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileSendCoordinatorTest.kt new file mode 100644 index 00000000..a2146f5e --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileSendCoordinatorTest.kt @@ -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() + 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(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() + 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() + 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() + 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() + 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(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 = this.visible, + ) = AncTileState.Active( + current = current, + pending = pending, + visible = visible, + deviceLabel = "Pods", + deviceAddress = address, + ) +} diff --git a/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileStateMapperTest.kt b/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileStateMapperTest.kt new file mode 100644 index 00000000..8ac8a5eb --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileStateMapperTest.kt @@ -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() + 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() + } + + @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() + 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() + 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 + } +} diff --git a/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileStateStoreTest.kt b/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileStateStoreTest.kt new file mode 100644 index 00000000..c93c7f79 --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/tile/AncTileStateStoreTest.kt @@ -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() + activeBeforeGap.current shouldBe off + + listener.cancel() + runCurrent() + + val activeAfterGap = store.currentState() + activeAfterGap.shouldBeInstanceOf() + 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() + 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() + 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() + 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>, + profiles: MutableStateFlow> = MutableStateFlow(emptyList()), + isPro: MutableStateFlow = MutableStateFlow(true), + bluetoothEnabled: MutableStateFlow = MutableStateFlow(true), + missingPermissions: MutableStateFlow> = MutableStateFlow(emptySet()), + sendCoordinator: AncTileSendCoordinator = coordinator(), + ): AncTileStateStore { + val deviceMonitor = mockk { + every { this@mockk.devices } returns devices + } + val profilesRepo = mockk { + every { this@mockk.profiles } returns profiles + } + val upgradeRepo = mockk { + every { upgradeInfo } returns MutableStateFlow(upgradeInfo(isPro.value)) + } + val bluetoothManager = mockk { + every { isBluetoothEnabled } returns bluetoothEnabled + } + val permissionTool = mockk { + 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 + } +} diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTierTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTierTest.kt new file mode 100644 index 00000000..2d4d1a7b --- /dev/null +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTierTest.kt @@ -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().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 + } +}