mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
feat(tile): Add ANC Quick Settings tile
This commit is contained in:
@@ -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 }
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user