Compare commits

..
Author SHA1 Message Date
d4rken-org-releaser[bot] 5d30844fa8 Release: 5.1.7-rc0 2026-06-08 07:22:52 +00:00
Matthias Urhahn af1b1b4249 Revert #605's cooldown-only mitigation in favor of the root-cause fix
@mvanhorn independently fixed #598 in #605 by no longer resetting the
case cooldown on close, throttling the re-pop. We cherry-picked that
commit above to keep his authorship/credit, but this PR instead removes
the underlying lid-state flapping at its source (out-of-case pod's stale
lid byte -> UNKNOWN), so the cooldown can keep resetting on close and a
genuine close->reopen still shows the popup. Reverting his change here so
the two approaches don't stack; thanks @mvanhorn for the parallel work.
2026-06-08 08:05:50 +02:00
Matt Van Horn 2cce9ae56c fix: case-open popup re-appears after the case is closed 2026-06-08 08:05:50 +02:00
Matthias Urhahn 4bab7c61a9 fix(reaction): Stop case popup flicker when one AirPod is out of case
When one pod is out of the case, the out-of-case pod broadcasts bit4-only
frames whose lid byte is stale and decodes to a phantom OPEN even while the
case is shut, interleaved ~1:1 with the correct in-case-pod (bit6) frames.
The derived lid state flapped OPEN<->CLOSED, so the case-open popup re-popped
~0.5s after closing and sometimes lingered. Verified on AirPods Pro 1 (issue
log) and Pro 3 (live BLE capture).

- Trust the lid bit only from in-case-pod (bit6) or both-in-case (bit2) frames;
  bit4-only frames now decode to UNKNOWN, and the real state is recovered from a
  recent in-case broadcast (matches LibrePods).
- getLatestCaseLidState recovers from history within a 2s age window instead of a
  fixed frame count, so a missed CLOSED can't keep a stale OPEN.
- Don't refresh the show-cooldown on a non-CLOSED hide, so a transient UNKNOWN
  can't suppress a genuine re-open.
- Add a freshness backstop: dismiss the popup if a fresh OPEN broadcast stops
  arriving (device left BLE range while open).
- Key popup/auto-connect de-duplication on the derived lid state, not just raw
  advertisement bytes, since the effective lid can change while bytes don't.

Closes #598
2026-06-08 08:05:50 +02:00
Matthias Urhahn 81e2bc1dba feat(overview): Suggest troubleshooter when a connected device sends no data
Adds a dashboard hint card pointing at the existing Troubleshooter when a profile is connected to the system (audio) but CAPod is receiving no live data — the symptom of a phone dropping AirPods BLE broadcasts (e.g. some HyperOS devices, #603). Debounced ~15s so it doesn't flash during the gap between an audio connection and the first broadcast, and suppressed whenever any pod is live so it never claims 'no data' while data is visibly arriving.
2026-06-08 07:45:29 +02:00
Matthias Urhahn df2783d8a1 feat(troubleshooter): Persist only the minimal working compat combo
Probe compatibility options through a transient in-memory override on BlePodMonitor instead of writing the user's persisted settings on every attempt. Only the winning combo is persisted, and only on success; failure or cancellation clears the override, restoring the user's original settings. Combos are tried fewest-disables-first so a phone that only needs batching disabled isn't left with filtering disabled too. Per-attempt cache clearing plus a freshness cutoff stop a previous combo's cached devices from satisfying the next one, and the 'found' checks now require a fresh live BLE observation rather than cached/AAP state.
2026-06-08 07:45:29 +02:00
Matthias Urhahn 20a6953b54 General: Update app translations from Crowdin 2026-06-07 13:30:43 +02:00
darken fdd4f69247 feat(overview): Add setting to hide unmatched devices 2026-05-31 10:04:26 +02:00
darken 5ee777b196 fix(reaction): Don't resume media mid-talk on Conversational Awareness
The pod only signals CA start and end, not continuous keep-alives. On fw 6861 it held CA engaged for 21s with zero 0x4B frames while the wearer kept talking, so the 12s stale-timeout fired mid-speech and resumed media; the pod never re-sent a start frame, so it didn't re-pause.

Disengage now waits for the explicit not-speaking frame (status 5 added as a terminal STOP, since that firmware winds down 3->5 and never reaches 6/8/9). Transitional/unknown statuses stay engaged. The stale timer is demoted to a long 5min backstop for a fully-dropped terminal frame; constants moved to Kotlin Durations.
2026-05-31 06:39:52 +02:00
30 changed files with 1076 additions and 268 deletions
+1 -1
View File
@@ -1 +1 @@
5.1.6-rc0 50106000
5.1.7-rc0 50107000
+1 -1
View File
@@ -6,6 +6,6 @@
<string name="upgrade_foss_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">دعم المشروع</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود من Google Play.</string>
<string name="upgrade_foss_sponsor_returned_early">هل عدتم فعلًا؟ دعمكم يُبقي CAPod حيًا.</string>
<string name="upgrade_foss_sponsor_returned_early">هل عدت فعلًا؟ دعمكم يُبقي CAPod حيًا.</string>
<string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string>
</resources>
@@ -83,6 +83,8 @@ class GeneralSettings @Inject constructor(
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
val themeMode = dataStore.createValue(
"core.ui.theme.mode", ThemeMode.SYSTEM, json,
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV,
@@ -61,6 +61,7 @@ import eu.darken.capod.main.ui.overview.cards.NoProfilesCard
import eu.darken.capod.main.ui.overview.cards.PermissionCard
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard
import eu.darken.capod.main.ui.overview.cards.SinglePodsCard
import eu.darken.capod.main.ui.overview.cards.TroubleshootSuggestionCard
import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
import eu.darken.capod.monitor.core.PodDevice
@@ -172,6 +173,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
},
onManageDevices = { vm.goToDeviceManager() },
onSettings = { vm.goToSettings() },
onTroubleShooter = { vm.goToTroubleShooter() },
onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
@@ -194,6 +196,7 @@ fun OverviewScreen(
onBluetoothSettings: () -> Unit,
onManageDevices: () -> Unit,
onSettings: () -> Unit,
onTroubleShooter: () -> Unit = {},
onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
@@ -351,18 +354,25 @@ fun OverviewScreen(
}
}
// 4c. Troubleshooter suggestion — connected via audio but no live data is reaching us
if (state.showTroubleshootSuggestion) {
item(key = "troubleshoot_suggestion") {
TroubleshootSuggestionCard(onTroubleShooter = onTroubleShooter)
}
}
// 5. Monitoring active card
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) {
if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
item(key = "monitoring_active") {
MonitoringActiveCard()
}
}
// 6. Unmatched devices section
if (state.unmatchedDevices.isNotEmpty()) {
if (state.shouldShowUnmatchedSection) {
item(key = "unmatched_header") {
UnmatchedDevicesCard(
count = state.unmatchedDevices.size,
count = state.visibleUnmatchedDevices.size,
isExpanded = state.showUnmatchedDevices,
onToggle = onToggleUnmatched,
)
@@ -370,7 +380,7 @@ fun OverviewScreen(
if (state.showUnmatchedDevices) {
itemsIndexed(
items = state.unmatchedDevices,
items = state.visibleUnmatchedDevices,
key = { index, device -> unmatchedDeviceKey(device, index) },
) { _, device ->
PodDeviceCard(
@@ -34,9 +34,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine as combineFlows
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
@@ -78,6 +81,59 @@ class OverviewViewModel @Inject constructor(
private val showUnmatchedDevices = MutableStateFlow(false)
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
private data class OverviewUiSettings(
val reactionsHintDismissed: Boolean,
val hideUnmatchedDevices: Boolean,
val showTroubleshootSuggestion: Boolean,
)
/**
* True when a profile is connected to the system (audio) but CAPod has *no* live data for any
* device — the classic "broadcasts are being dropped by the ROM" symptom (e.g. some HyperOS
* phones, see #603). Surfaced as a hint pointing at the Troubleshooter, which can probe and
* persist a working compatibility combo. Debounced so it doesn't flash during the brief window
* between an audio connection and the first BLE broadcast. Suppressed whenever any pod is live,
* so it never claims "no data" while data is visibly arriving (incl. the #603 duplicate state).
*/
private val troubleshootSuggestion = combineFlows(
profilesRepo.profiles,
bluetoothManager.connectedDevices.onStart { emit(emptyList()) },
deviceMonitor.devices,
permissionTool.missingScanPermissions,
) { profiles, connectedDevices, devices, missingScanPermissions ->
val connectedAddresses = connectedDevices.mapTo(mutableSetOf()) { it.address }
val anyProfileConnected = profiles.any { it.address != null && it.address in connectedAddresses }
val anyLivePod = devices.any { it.isLive }
missingScanPermissions.isEmpty() && anyProfileConnected && !anyLivePod
}
.distinctUntilChanged()
.flatMapLatest { conditionMet ->
if (conditionMet) flow {
delay(TROUBLESHOOT_SUGGESTION_DELAY_MS)
emit(true)
} else flowOf(false)
}
.onStart { emit(false) }
.distinctUntilChanged()
private val overviewUiSettings = combineFlows(
generalSettings.reactionsHintDismissed.flow,
generalSettings.hideUnmatchedDevices.flow,
troubleshootSuggestion,
) { reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion ->
OverviewUiSettings(reactionsHintDismissed, hideUnmatched, showTroubleshootSuggestion)
}
init {
// When the persistent "hide unmatched" setting is enabled, reset the in-session expand
// toggle so the section reappears collapsed if the user later disables the setting.
launch {
generalSettings.hideUnmatchedDevices.flow
.filter { it }
.collect { showUnmatchedDevices.value = false }
}
}
val workerAutolaunch = combine(
permissionTool.missingScanPermissions,
monitorModeResolver.effectiveMode,
@@ -134,9 +190,9 @@ class OverviewViewModel @Inject constructor(
upgradeRepo.upgradeInfo,
showUnmatchedDevices,
userExpansionOverrides,
generalSettings.reactionsHintDismissed.flow,
overviewUiSettings,
profilesRepo.hadLegacyReactionData,
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, reactionsHintDismissed, hadLegacyReactionData ->
) { _, permissions, devices, isDebug, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds, uiSettings, hadLegacyReactionData ->
// Prune stale overrides (profiles that no longer exist)
val currentProfileIds = profiles.map { it.id }.toSet()
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
@@ -151,7 +207,9 @@ class OverviewViewModel @Inject constructor(
upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched,
userExpandedIds = prunedExpandedIds,
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed,
showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
)
}.asLiveState()
@@ -168,6 +226,8 @@ class OverviewViewModel @Inject constructor(
val showUnmatchedDevices: Boolean,
val userExpandedIds: Set<String> = emptySet(),
val showReactionsHint: Boolean = false,
val hideUnmatchedDevices: Boolean = false,
val showTroubleshootSuggestion: Boolean = false,
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
@@ -188,6 +248,11 @@ class OverviewViewModel @Inject constructor(
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null }
/** Unmatched devices actually shown on the dashboard (empty when the hide setting is on). */
val visibleUnmatchedDevices: List<PodDevice>
get() = if (hideUnmatchedDevices) emptyList() else unmatchedDevices
val shouldShowUnmatchedSection: Boolean get() = visibleUnmatchedDevices.isNotEmpty()
val bluetoothIconState: BluetoothIconState
get() = when {
isScanBlocked -> BluetoothIconState.HIDDEN
@@ -226,6 +291,11 @@ class OverviewViewModel @Inject constructor(
navTo(Nav.Settings.Index)
}
fun goToTroubleShooter() {
log(TAG, INFO) { "goToTroubleShooter()" }
navTo(Nav.Main.TroubleShooter)
}
fun goToDeviceManager() {
log(TAG, INFO) { "goToDeviceManager()" }
navTo(Nav.Main.DeviceManager)
@@ -286,6 +356,14 @@ class OverviewViewModel @Inject constructor(
companion object {
private const val FREE_DEVICE_LIMIT = 1
/**
* How long the "connected but no live data" condition must hold before the troubleshooter
* hint appears, so it doesn't flash during the gap between an audio connection and the first
* BLE broadcast.
*/
private const val TROUBLESHOOT_SUGGESTION_DELAY_MS = 15_000L
private val TAG = logTag("Overview", "VM")
}
}
@@ -0,0 +1,71 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Troubleshoot
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun TroubleshootSuggestionCard(onTroubleShooter: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.TwoTone.Troubleshoot,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = stringResource(R.string.overview_troubleshoot_suggestion_label),
style = MaterialTheme.typography.titleMedium,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.overview_troubleshoot_suggestion_description),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onTroubleShooter,
modifier = Modifier.align(Alignment.End),
) {
Text(text = stringResource(R.string.overview_troubleshoot_suggestion_action))
}
}
}
}
@Preview2
@Composable
private fun TroubleshootSuggestionCardPreview() = PreviewWrapper {
TroubleshootSuggestionCard(onTroubleShooter = {})
}
@@ -12,6 +12,7 @@ import androidx.compose.material.icons.twotone.FilterList
import androidx.compose.material.icons.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.Notifications
import androidx.compose.material.icons.twotone.Palette
import androidx.compose.material.icons.twotone.VisibilityOff
import androidx.compose.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -58,6 +59,7 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) },
onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
onThemeColorSelected = { color -> vm.setThemeColor(color) },
@@ -75,6 +77,7 @@ fun GeneralSettingsScreen(
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
onThemeModeSelected: (ThemeMode) -> Unit = {},
onThemeStyleSelected: (ThemeStyle) -> Unit = {},
onThemeColorSelected: (ThemeColor) -> Unit = {},
@@ -197,6 +200,21 @@ fun GeneralSettingsScreen(
},
)
}
item {
SettingsBaseItem(
title = stringResource(R.string.settings_overview_hide_unmatched_label),
subtitle = stringResource(R.string.settings_overview_hide_unmatched_description),
icon = Icons.TwoTone.VisibilityOff,
onClick = { onHideUnmatchedDevicesChanged(!state.hideUnmatchedDevices) },
trailingContent = {
Switch(
checked = state.hideUnmatchedDevices,
onCheckedChange = onHideUnmatchedDevicesChanged,
modifier = Modifier.padding(start = 16.dp),
)
},
)
}
item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title))
}
@@ -267,6 +285,7 @@ private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State
isOffloadedFilteringDisabled = false,
isOffloadedBatchingDisabled = false,
useIndirectScanResultCallback = false,
hideUnmatchedDevices = false,
themeState = ThemeState(),
)
@@ -281,6 +300,7 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
)
}
@@ -295,5 +315,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
)
}
@@ -34,6 +34,7 @@ class GeneralSettingsViewModel @Inject constructor(
val isOffloadedFilteringDisabled: Boolean,
val isOffloadedBatchingDisabled: Boolean,
val useIndirectScanResultCallback: Boolean,
val hideUnmatchedDevices: Boolean,
val themeState: ThemeState,
)
@@ -57,7 +58,8 @@ class GeneralSettingsViewModel @Inject constructor(
},
generalSettings.themeState,
isPro,
) { general, compat, themeState, isPro ->
generalSettings.hideUnmatchedDevices.flow,
) { general, compat, themeState, isPro, hideUnmatched ->
State(
isPro = isPro,
showConnectedNotification = general[0] as Boolean,
@@ -65,6 +67,7 @@ class GeneralSettingsViewModel @Inject constructor(
isOffloadedFilteringDisabled = compat[0] as Boolean,
isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean,
hideUnmatchedDevices = hideUnmatched,
themeState = themeState,
)
}.asLiveState()
@@ -94,6 +97,11 @@ class GeneralSettingsViewModel @Inject constructor(
generalSettings.useIndirectScanResultCallback.valueBlocking = enabled
}
fun setHideUnmatchedDevices(enabled: Boolean) {
log(TAG, INFO) { "setHideUnmatchedDevices($enabled)" }
generalSettings.hideUnmatchedDevices.valueBlocking = enabled
}
fun setThemeMode(mode: ThemeMode) = launch {
log(TAG, INFO) { "setThemeMode($mode)" }
if (isPro.first()) {
@@ -59,6 +59,17 @@ class BlePodMonitor @Inject constructor(
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
private val cacheLock = Mutex()
/**
* Drops all cached observations. The troubleshooter calls this between probe attempts so a
* previous compat combo's cached devices (kept up to [STALE_DEVICE_TIMEOUT]) can't leak into
* the next attempt and falsely satisfy it — including via [preferCaseContextPod], which would
* otherwise hand back a stale snapshot whose timestamp predates the fresh scan.
*/
suspend fun clearDeviceCache() = cacheLock.withLock {
log(TAG) { "clearDeviceCache()" }
deviceCache.clear()
}
/**
* Ephemeral override that disables the proximity-pairing scan filter so
* the troubleshooter can collect raw BLE broadcasts. Resets to false on
@@ -70,6 +81,40 @@ class BlePodMonitor @Inject constructor(
unfilteredOverride.value = enabled
}
/**
* Ephemeral override for the three BLE compatibility options. The troubleshooter uses this to
* probe combinations without writing the user's persisted settings: while set, it fully replaces
* the persisted compat values for the active scan. Resets to null on every process start; the
* troubleshooter is the only writer and always clears it when finished, so clearing it restores
* the user's original settings for free.
*/
private val compatOverride = MutableStateFlow<CompatOverride?>(null)
fun setCompatOverride(override: CompatOverride?) {
log(TAG) { "setCompatOverride($override)" }
compatOverride.value = override
}
data class CompatOverride(
val offloadedFilteringDisabled: Boolean,
val offloadedBatchingDisabled: Boolean,
val indirectCallback: Boolean,
)
/** Persisted compat settings, transparently replaced by [compatOverride] while it is set. */
private val effectiveCompat: Flow<CompatOverride> = combine(
compatOverride,
generalSettings.isOffloadedFilteringDisabled.flow,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.useIndirectScanResultCallback.flow,
) { override, filteringDisabled, batchingDisabled, indirectCallback ->
override ?: CompatOverride(
offloadedFilteringDisabled = filteringDisabled,
offloadedBatchingDisabled = batchingDisabled,
indirectCallback = indirectCallback,
)
}
val devices: Flow<List<BlePodSnapshot>> = combine(
permissionTool.missingScanPermissions,
bluetoothManager.isBluetoothEnabled
@@ -145,22 +190,14 @@ class BlePodMonitor @Inject constructor(
private fun createBleScanner() = combine(
bleScanModeController.scannerMode,
unfilteredOverride,
generalSettings.isOffloadedBatchingDisabled.flow,
generalSettings.isOffloadedFilteringDisabled.flow,
generalSettings.useIndirectScanResultCallback.flow,
) {
scannermode,
showUnfiltered,
isOffloadedBatchingDisabled,
isOffloadedFilteringDisabled,
useIndirectScanResultCallback,
->
effectiveCompat,
) { scannermode, showUnfiltered, compat ->
ScannerOptions(
scannerMode = scannermode,
showUnfiltered = showUnfiltered,
offloadedFilteringDisabled = isOffloadedFilteringDisabled,
offloadedBatchingDisabled = isOffloadedBatchingDisabled,
disableDirectCallback = useIndirectScanResultCallback,
offloadedFilteringDisabled = compat.offloadedFilteringDisabled,
offloadedBatchingDisabled = compat.offloadedBatchingDisabled,
disableDirectCallback = compat.indirectCallback,
)
}
.throttleLatest(1000)
@@ -102,10 +102,10 @@ sealed class AapSetting {
/**
* Push-only from device — reports speaking detection state (command 0x4B).
*
* [rawValue] is the first payload byte, preserved so consumers can distinguish the known
* speaking-start (0x01) and speaking-stop (0x04) markers from other values (e.g. 0x00, seen
* in captures with unclear meaning). [speaking] collapses everything non-0x01 to false for
* storage/UI; reaction logic must gate on [rawValue] to avoid acting on unknown values.
* [rawValue] is the status byte: the last byte of the 4-byte `02 00 01 XX` form (or the single
* byte of the legacy form), preserved so consumers can classify it. [speaking] is `true` only
* for the speaking-onset statuses (`1`, `2`); every other value (`0`, `3`, `4`, `5`, `0x0B`, …)
* is `false`. START/STOP/HOLD classification for the reaction lives in [ConversationAwarenessEvent].
*/
data class ConversationalAwarenessState(
val speaking: Boolean,
@@ -3,14 +3,19 @@ package eu.darken.capod.pods.core.apple.aap.protocol
/**
* Classified Conversational Awareness signal derived from the status byte of a `0x4B` frame.
*
* Status-byte mapping (confirmed against a live AirPods Pro 3 capture and the librepods project):
* Status-byte mapping (from live AirPods Pro 3 captures + the librepods project):
* - `1`, `2` → [START] (wearer started / is speaking → engage the reaction)
* - `6`, `8`, `9` → [STOP] (wearer stopped → disengage)
* - any other value (`3`, `4`, `0x0B`, …) → [HOLD] (intermediate "still in session" frame; the pod
* streams these while speaking — they act as a keep-alive and must NOT disengage the reaction)
* - `5`, `6`, `8`, `9` → [STOP] (wearer stopped → disengage). `5` is the terminal value on fw `…6861`,
* which winds down `3`→`5` and never reaches `6/8/9`; `6/8/9` are the terminal values on fw `…6503`.
* - any other value (`0`, `3`, `4`, `0x0B`, … and anything unrecognised) → [HOLD]: a transitional or
* unknown frame. It must NOT disengage the reaction — only an explicit terminal [STOP] does that.
*
* The pod emits no `0x4B` frames at all during silence, so [HOLD] frames ceasing is itself a
* reliable "speaking ended" signal (used as a stale-timeout fallback for a missed [STOP]).
* Frame cadence is firmware-dependent and the pod does NOT reliably stream keep-alives while you
* talk: fw `…6861` sent only an onset (`1`,`2`) then NO `0x4B` frames for 21s of continuous speech
* (proven still-speaking — it held its own CA/ANC-transparency engaged the whole time), then the
* wind-down `3`,`5`. So frame-silence must NOT be read as "speaking ended"; disengage is driven by
* the explicit terminal [STOP] frame. [ConversationReaction]'s stale timeout is only a long backstop
* for a fully-dropped terminal frame, not the normal disengage path.
*/
enum class ConversationAwarenessEvent {
START,
@@ -20,7 +25,7 @@ enum class ConversationAwarenessEvent {
companion object {
val SPEAKING_STATUSES = setOf(1, 2)
val STOPPED_STATUSES = setOf(6, 8, 9)
val STOPPED_STATUSES = setOf(5, 6, 8, 9)
fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
in SPEAKING_STATUSES -> START
@@ -4,6 +4,14 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import java.time.Duration
/**
* How far back to look when recovering the lid state from a recent in-case-pod broadcast.
* Time-bounded rather than count-bounded: BLE scan batching means a fixed number of frames is not a
* stable time window, and an old reading must not be allowed to resurrect a stale OPEN/CLOSED.
*/
private val MAX_LID_RECOVERY_AGE: Duration = Duration.ofSeconds(2)
interface ApplePodsFactory {
fun isResponsible(message: ProximityMessage): Boolean
@@ -22,7 +30,8 @@ interface ApplePodsFactory {
fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
// A pod broadcasting from inside the case has authoritative case state
// A pod broadcasting from inside the case has authoritative case state. Out-of-case frames
// (one pod removed) report UNKNOWN via DualApplePods.caseLidState and are skipped here.
if (basic.hasCaseContext && basic.caseLidState in setOf(
DualApplePods.LidState.OPEN,
DualApplePods.LidState.CLOSED,
@@ -31,11 +40,14 @@ interface ApplePodsFactory {
return basic.caseLidState
}
// Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN.
// Check recent history for a sibling broadcast that has case context.
val fromCaseContext = history
.takeLast(4)
// Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN (out-of-case pod's
// stale lid byte). Recover the last authoritative reading from a recent in-case broadcast,
// bounded by time so a missed CLOSED can't keep a stale OPEN alive across scan gaps.
val recentHistory = history
.filterIsInstance<DualApplePods>()
.filter { Duration.between(it.seenLastAt, basic.seenLastAt).abs() <= MAX_LID_RECOVERY_AGE }
val fromCaseContext = recentHistory
.lastOrNull { it.hasCaseContext && it.caseLidState != DualApplePods.LidState.UNKNOWN }
?.caseLidState
@@ -44,10 +56,8 @@ interface ApplePodsFactory {
// No case-context broadcast in recent history — current value is best we have
if (basic.caseLidState != DualApplePods.LidState.UNKNOWN) return basic.caseLidState
// Last resort: any non-UNKNOWN from history
return history
.takeLast(2)
.filterIsInstance<DualApplePods>()
// Last resort: any non-UNKNOWN from recent history
return recentHistory
.lastOrNull { it.caseLidState != DualApplePods.LidState.UNKNOWN }
?.caseLidState
?: DualApplePods.LidState.NOT_IN_CASE
@@ -148,7 +148,14 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
get() = isThisPodInThecase || isOnePodInCase || areBothPodsInCase
val caseLidState: LidState
get() = LidState.fromRaw(pubCaseLidState, hasCaseContext)
get() = LidState.fromRaw(
raw = pubCaseLidState,
hasCaseContext = hasCaseContext,
// The lid bit is only trustworthy from a pod broadcasting inside the case (bit 6), or
// while both pods are in the case (bit 2). A bit4-only frame comes from the out-of-case
// pod and carries a stale lid byte (see LidState.fromRaw).
lidReadingReliable = isThisPodInThecase || areBothPodsInCase,
)
/**
* TODO this is glitchy
@@ -165,8 +172,20 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
UNKNOWN;
companion object {
fun fromRaw(raw: UByte, hasCaseContext: Boolean): LidState {
/**
* Derives the lid state from the raw lid byte.
*
* The open/closed bit is only meaningful when broadcast by a pod that is itself inside
* the case ([isThisPodInThecase]) or while both pods are in the case ([areBothPodsInCase]).
* When only one pod is in the case and the *other*, out-of-case pod is the one
* broadcasting (bit4-only), its lid byte is stale and decodes to a phantom OPEN even while
* the case is physically shut (verified on AirPods Pro 1 & Pro 3). Such frames must report
* [UNKNOWN] so they don't drive case-open reactions; consumers recover the real state from
* an in-case-pod broadcast instead.
*/
fun fromRaw(raw: UByte, hasCaseContext: Boolean, lidReadingReliable: Boolean): LidState {
if (!hasCaseContext) return NOT_IN_CASE
if (!lidReadingReliable) return UNKNOWN
return when ((raw.toInt() shr 3) and 0x01) {
0 -> OPEN
@@ -39,7 +39,9 @@ class AutoConnect @Inject constructor(
combine(
bluetoothManager.connectedDevices,
deviceMonitor.primaryDevice().filterNotNull().distinctUntilChangedBy {
Triple(it.rawDataHex, it.reactions.autoConnectCondition, it.reactions.onePodMode)
// Include caseLidState: for the CASE_OPEN condition it is derived from history
// and can flip to OPEN while the selected frame's raw bytes are unchanged.
listOf(it.rawDataHex, it.reactions.autoConnectCondition, it.reactions.onePodMode, it.caseLidState)
},
) { connectedDevices, mainDevice ->
connectedDevices to mainDevice
@@ -30,17 +30,22 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
/**
* Reacts to Conversational Awareness speaking transitions (AAP `0x4B`) by either lowering media
* volume or pausing, per the primary device's [ReactionConfig.conversationAction], and reverts when
* speaking stops. On Android the pod firmware does not duck audio itself, so CAPod performs it.
*
* The pod streams classified frames while you talk ([ConversationAwarenessEvent.START] at onset,
* then [ConversationAwarenessEvent.HOLD] keep-alives) and an explicit [ConversationAwarenessEvent.STOP]
* when you finish; no frames at all during silence. Disengage happens on STOP, or — if a STOP is
* dropped — via a stale timeout that fires once frames stop arriving. Each frame (START or HOLD)
* resets that timer, so a long conversation stays engaged.
* Disengage is driven by the pod's explicit end-of-speech frame ([ConversationAwarenessEvent.STOP]).
* The pod does NOT reliably stream keep-alive frames while you talk — on some firmware it sends an
* onset ([ConversationAwarenessEvent.START]) then nothing for many seconds while speech continues
* (observed: CA held engaged 21s with zero `0x4B` frames). So frame-silence must NOT be read as
* "speaking ended". [STALE_TIMEOUT] is only a long backstop for the rare case where every terminal
* frame is lost while the link stays up; a link drop is handled by the owner-disconnect revert.
* [ConversationAwarenessEvent.HOLD] frames (transitional/unknown statuses) keep the reaction engaged
* and refresh the backstop.
*
* State is a single global slot (media volume / playback is system-wide, not per-device) guarded by
* a [Mutex] — events, AAP-state-removal, the stale timer, and monitor completion all mutate it.
@@ -182,7 +187,7 @@ class ConversationReaction @Inject constructor(
// surprising behaviour. The remaining guards are about real device/playback state.
val age = timeSource.elapsedRealtime() - record.at
when {
age > PAUSE_RESUME_WINDOW_MS ->
age.milliseconds > PAUSE_RESUME_WINDOW ->
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
primary?.address != record.owner ->
log(TAG) { "$reason — resume skipped (primary switched)" }
@@ -242,15 +247,15 @@ class ConversationReaction @Inject constructor(
}
/**
* Must be called under [mutex]. (Re)starts the stale timer for [record]. Reset on every frame
* (START/HOLD); if no frame arrives for [STALE_TIMEOUT_MS] the speaking session is treated as
* ended (recovers from a dropped STOP). Identity-checked so a late timer can't disengage a newer
* session.
* Must be called under [mutex]. (Re)starts the backstop timer for [record], reset on every frame
* (START/HOLD). If no frame arrives for [STALE_TIMEOUT] the session is force-ended — a last
* resort for a fully-dropped terminal frame, not the normal disengage. Identity-checked so a
* late timer can't disengage a newer session.
*/
private fun restartStaleTimer(record: Active) {
staleJob?.cancel()
staleJob = appScope.launch {
delay(STALE_TIMEOUT_MS)
delay(STALE_TIMEOUT)
val primary = deviceMonitor.primaryDevice().first()
mutex.withLock {
if (active?.id == record.id) {
@@ -268,14 +273,17 @@ class ConversationReaction @Inject constructor(
private val TAG = logTag("Reaction", "Conversation")
/**
* Disengage if no `0x4B` frame arrives for this long while engaged. The pod streams frames
* (~1/s) throughout active speech and none during silence, so this both recovers a dropped
* STOP and bounds how long a duck can linger. Long enough not to disengage mid-conversation
* between frames.
* Pure backstop: disengage if no `0x4B` frame arrives for this long while engaged. This is
* NOT the normal disengage path — the pod does not stream keep-alives during speech, so a
* short timeout would fire mid-conversation (the original 12s value did exactly that).
* Normal disengage is the explicit terminal [ConversationAwarenessEvent.STOP] frame; this
* only recovers a fully-dropped terminal frame while the link stays up. Kept longer than
* [PAUSE_RESUME_WINDOW] so a back-stopped pause is never auto-resumed (only a duck is
* restored on stale — a stranded low volume is the worse failure).
*/
private const val STALE_TIMEOUT_MS = 12L * 1000L
private val STALE_TIMEOUT = 5.minutes
/** A disengage older than this no longer auto-resumes a pause — unexpected late playback is worse. */
private const val PAUSE_RESUME_WINDOW_MS = 2L * 60L * 1000L
/** A pause older than this no longer auto-resumes — unexpected late playback is worse. */
private val PAUSE_RESUME_WINDOW = 2.minutes
}
}
@@ -13,9 +13,12 @@ import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge
import java.time.Duration
@@ -34,8 +37,12 @@ class PopUpReaction @Inject constructor(
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDevice()
.distinctUntilChangedBy {
// Re-emit on profile changes (eligibility) AND on raw BLE state changes (lid).
Triple(it?.profileId, it?.reactions?.showPopUpOnCaseOpen, it?.rawDataHex)
// Re-emit on profile changes (eligibility), raw BLE changes (content), AND the derived
// lid state. The latter is essential: caseLidState is recovered from history, so it can
// flip OPEN<->CLOSED while the *selected* frame's raw bytes stay identical (e.g. a steady
// out-of-case frame while a sibling in-case frame updates). Keying on rawDataHex alone
// would swallow that transition and the popup would miss its show/hide.
listOf(it?.profileId, it?.reactions?.showPopUpOnCaseOpen, it?.rawDataHex, it?.caseLidState)
}
.withPrevious()
.setupCommonEventHandlers(TAG) { "popUpCase" }
@@ -71,7 +78,7 @@ class PopUpReaction @Inject constructor(
throttleCasePopUps(current)
}
private fun throttleCasePopUps(current: PodDevice): Event? {
internal fun throttleCasePopUps(current: PodDevice): Event? {
val cooldownKey = current.profileId ?: current.identifier?.toString() ?: return null
val now = timeSource.now()
val lastShown = caseCoolDowns[cooldownKey]
@@ -95,9 +102,9 @@ class PopUpReaction @Inject constructor(
}
decision.shouldHide -> {
if (!decision.shouldResetCooldown) {
caseCoolDowns[cooldownKey] = now
}
// Don't stamp the cooldown on a non-CLOSED hide (UNKNOWN/NOT_IN_CASE). Refreshing it
// here would let a transient UNKNOWN (e.g. a brief out-of-case frame) suppress a
// genuine OPEN for the whole cooldown window. CLOSED still resets it above.
Event.PopupHide(now)
}
@@ -192,7 +199,48 @@ class PopUpReaction @Inject constructor(
}
.setupCommonEventHandlers(TAG) { "popUpConnection" }
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection())
/**
* Backstop for case-open popups that never receive a CLOSED frame because the device left BLE
* range while the lid was open — otherwise the overlay lingers until manually dismissed (one of
* the symptoms in #598). A ticker re-checks the primary device's freshness; once a previously
* fresh OPEN broadcast goes stale past [CASE_OPEN_STALE_TIMEOUT] (no newer advertisement, or the
* device dropped to cache-only), a single Hide is emitted. The lid-driven [monitorCase] still
* handles the normal close; a redundant Hide here is harmless ([PopUpWindow.close] is idempotent).
*/
private fun monitorCaseStaleClose(): Flow<Event> = combine(
deviceMonitor.primaryDevice(),
staleCheckTicker(),
) { device, _ -> isCaseOpenBroadcastFresh(device) }
.distinctUntilChanged()
.withPrevious()
.mapNotNull { (wasFresh, isFresh) ->
if (wasFresh == true && !isFresh) {
log(TAG) { "Case-open broadcast went stale, emitting Hide" }
Event.PopupHide(timeSource.now())
} else {
null
}
}
.setupCommonEventHandlers(TAG) { "popUpCaseStale" }
/** True while the primary device is eligible and currently advertising a fresh OPEN lid. */
internal fun isCaseOpenBroadcastFresh(device: PodDevice?): Boolean {
if (device?.reactions?.showPopUpOnCaseOpen != true) return false
if (device.caseLidState != DualApplePods.LidState.OPEN) return false
// Track BLE freshness specifically, not PodDevice.seenLastAt (which also counts AAP/cache):
// the lid is a BLE-only signal, so a live AAP socket must not keep a stale OPEN on screen.
val bleSeenLastAt = device.ble?.seenLastAt ?: return false
return Duration.between(bleSeenLastAt, timeSource.now()) <= CASE_OPEN_STALE_TIMEOUT
}
private fun staleCheckTicker(): Flow<Unit> = flow {
while (true) {
emit(Unit)
delay(STALE_CHECK_INTERVAL.toMillis())
}
}
fun monitor(): Flow<Event> = merge(monitorCase(), monitorConnection(), monitorCaseStaleClose())
sealed class Event {
data class PopupShow(
@@ -296,5 +344,11 @@ class PopUpReaction @Inject constructor(
companion object {
private val TAG = logTag("Reaction", "PopUp")
/** A case-open popup is force-dismissed once its OPEN broadcast hasn't refreshed for this long. */
private val CASE_OPEN_STALE_TIMEOUT: Duration = Duration.ofSeconds(4)
/** How often the stale-close backstop re-evaluates freshness. */
private val STALE_CHECK_INTERVAL: Duration = Duration.ofSeconds(2)
}
}
@@ -9,6 +9,7 @@ import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
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.uix.ViewModel4
@@ -22,14 +23,13 @@ import eu.darken.capod.pods.core.unknown.UnknownSnapshotBle
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
@@ -47,6 +47,9 @@ class TroubleShooterViewModel @Inject constructor(
private val _bleState = MutableStateFlow<BleState>(BleState.Intro())
/** Guards against re-entrant runs (e.g. a double tap on "Try again"). */
private val runLock = Mutex()
data class State(val bleState: BleState)
val state = _bleState.map { State(it) }.asLiveState()
@@ -82,169 +85,184 @@ class TroubleShooterViewModel @Inject constructor(
}
fun troubleShootBle() = launch(context = dispatcherProvider.IO) {
if (!runLock.tryLock()) {
log(TAG, WARN) { "troubleShootBle() ignored, a run is already in progress" }
return@launch
}
log(TAG, INFO) { "troubleShootBle()" }
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
try {
run {
progress("Checking for headphones...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Headphones found, nothing to troubleshoot.")
return@override
} else {
progress("Headphones not detected.\n")
}
}
try {
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
// The combo under which supported headphones were detected (set in sweep 2).
var supportedCombo: BlePodMonitor.CompatOverride? = null
// Set only when the run reaches a terminal success. Persisted on exit; staying null
// (any failure, or cancellation) means we just clear the override, which restores the
// user's original — never-touched — settings.
var comboToPersist: BlePodMonitor.CompatOverride? = null
try {
run {
progress("Checking for headphones...")
if (findLiveBleHeadphones()) {
success("Headphones found, nothing to troubleshoot.")
return@override
} else {
progress("Headphones not detected.\n")
}
}
val doScan: suspend (Boolean, Boolean, Boolean, Boolean) -> Collection<BlePodSnapshot> =
{ hardwareFilteringDisabled,
hardwareBatchingDisabled,
indirectCallback,
unfiltered ->
val sb = StringBuilder("SCAN - Settings: ")
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ")
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ")
sb.append("indirectCallback=$indirectCallback, ")
sb.append("unfiltered=$unfiltered")
progress(sb.toString())
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback
blePodMonitor.setUnfilteredOverride(unfiltered)
val doScan: suspend (BlePodMonitor.CompatOverride, Boolean) -> Collection<BlePodSnapshot> =
{ combo, unfiltered ->
progress(
"SCAN - Settings: filteringDisabled=${combo.offloadedFilteringDisabled}, " +
"batchingDisabled=${combo.offloadedBatchingDisabled}, " +
"indirectCallback=${combo.indirectCallback}, unfiltered=$unfiltered"
)
blePodMonitor.setCompatOverride(combo)
blePodMonitor.setUnfilteredOverride(unfiltered)
val devices = collectFreshDevices()
log(TAG) { "SCAN: Fresh BLE devices: $devices" }
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices")
} else {
progress("SCAN: No data received")
}
devices
}
val start = timeSource.elapsedRealtime()
val devices = withTimeoutOrNull(STEP_TIME) {
blePodMonitor.devices
.take(10)
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
log(TAG) { "SCAN: BLE Devices: $devices" }
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices")
devices
} else {
progress("SCAN: No data received")
devices
run {
progress("Checking if we can receive BLE data at all.")
val gotData = COMPAT_COMBOS.any { combo -> doScan(combo, true).isNotEmpty() }
if (!gotData) {
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
return@override
}
}
progress("We received at least some BLE data.\n")
run {
progress("Checking for supported headphones.")
supportedCombo = COMPAT_COMBOS.firstOrNull { combo ->
doScan(combo, false).any { it !is UnknownSnapshotBle }
}
if (supportedCombo == null) {
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
return@override
}
}
progress("Found some headphones that are supported by CAPod.\n")
run {
progress("Checking for your headphones with new BLE settings...")
if (findLiveBleHeadphones()) {
comboToPersist = supportedCombo
success("Found your headphones, new BLE settings worked :)!")
return@override
}
}
progress("Still no headphones detected that count as yours.\n")
run {
progress("Checking all closeby headphones.")
val otherDevices = collectFreshDevices()
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
val candidate = otherDevices
.filter { it !is UnknownSnapshotBle }
.maxByOrNull { it.signalQuality }
if (candidate == null) {
failure(
"No supported headphones found near your device.",
BleState.Result.Failure.Type.HEADPHONES,
)
return@override
}
progress("Headphones found nearby, but not detected as yours.\n")
progress("Creating profile for closest headphones.")
log(TAG, INFO) { "Candidate is $candidate" }
profilesRepo.addProfile(
profile = AppleDeviceProfile(
label = context.getString(R.string.troubleshooter_title),
model = candidate.model,
),
addFirst = true,
)
if (findLiveBleHeadphones()) {
comboToPersist = supportedCombo
success("Success! Detected your headphones.")
} else {
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
}
}
} finally {
blePodMonitor.setUnfilteredOverride(false)
try {
// Persist the winning combo before dropping the override so the effective scan
// settings stay equal with no restart flicker. Done in its own try so the
// override is still cleared (restoring originals) even if a write throws.
comboToPersist?.let { persistCompat(it) }
} finally {
blePodMonitor.setCompatOverride(null)
}
}
run {
progress("Checking if we can receive BLE data at all.")
if (doScan(false, false, false, true).isNotEmpty()) return@run
if (doScan(false, false, true, true).isNotEmpty()) return@run
if (doScan(true, true, true, true).isNotEmpty()) return@run
if (doScan(true, true, false, true).isNotEmpty()) return@run
if (doScan(true, false, true, true).isNotEmpty()) return@run
if (doScan(true, false, false, true).isNotEmpty()) return@run
if (doScan(false, true, true, true).isNotEmpty()) return@run
if (doScan(false, true, false, true).isNotEmpty()) return@run
failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false
return@override
}
progress("We received at least some BLE data.\n")
run {
progress("Checking for supported headphones.")
if (doScan(false, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, true, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(true, false, false, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, true, false).any { it !is UnknownSnapshotBle }) return@run
if (doScan(false, true, false, false).any { it !is UnknownSnapshotBle }) return@run
failure("No compatible headphones found", BleState.Result.Failure.Type.HEADPHONES)
generalSettings.isOffloadedFilteringDisabled.valueBlocking = false
generalSettings.isOffloadedBatchingDisabled.valueBlocking = false
generalSettings.useIndirectScanResultCallback.valueBlocking = false
return@override
}
progress("Found some headphones that are supported by CAPod.\n")
run {
progress("Checking for your headphones with new BLE settings...")
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Found your headphones, new BLE settings worked :)!")
return@override
}
}
progress("Still no headphones detected that count as yours.\n")
run {
progress("Checking all closeby headphones.")
val otherDevices = withTimeoutOrNull(STEP_TIME) {
val start = timeSource.elapsedRealtime()
blePodMonitor.devices
.take(10)
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
.toList()
.flatten()
.distinctBy { it.address }
} ?: emptyList()
otherDevices.forEachIndexed { index, dev -> log(TAG) { "Device #$index: $dev" } }
if (otherDevices.isEmpty()) {
failure("No supported headphones found near your device.", BleState.Result.Failure.Type.HEADPHONES)
return@override
}
progress("Headphones found nearby, but not detected as yours.\n")
progress("Creating profile for closest headphones.")
val candidate = otherDevices
.filter { it !is UnknownSnapshotBle }
.maxBy { it.signalQuality }
log(TAG, INFO) { "Candidate is $candidate" }
profilesRepo.addProfile(
profile = AppleDeviceProfile(
label = context.getString(R.string.troubleshooter_title),
model = candidate.model,
),
addFirst = true,
)
val mainDevice = withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().filterNotNull().firstOrNull()
}
if (mainDevice != null) {
success("Success! Detected your headphones.")
} else {
failure("No headphones detected near your device.", BleState.Result.Failure.Type.HEADPHONES)
}
}
} finally {
blePodMonitor.setUnfilteredOverride(false)
}
} finally {
runLock.unlock()
}
}
/**
* Waits up to [STEP_TIME] for the primary profile to be backed by a *fresh, live BLE*
* observation. A cached-only / AAP-only primary does not count — the troubleshooter is about
* whether BLE advertisements are actually reaching us. The freshness cutoff (snapshot seen at or
* after this call) means it reflects the currently-active scan settings and doesn't rely on the
* device cache having been cleared beforehand.
*/
private suspend fun findLiveBleHeadphones(): Boolean {
val threshold = timeSource.now()
return withTimeoutOrNull(STEP_TIME) {
deviceMonitor.primaryDevice().firstOrNull { device ->
device?.ble != null && (device.seenLastAt?.let { it >= threshold } == true)
} != null
} ?: false
}
/**
* Collects BLE devices observed *after the current scan settings take effect*. Option changes
* restart the scan after a throttle, and [BlePodMonitor] keeps a 20s device cache, so without a
* freshness cutoff a stale observation from a previous combo could be mistaken for a "win".
*/
private suspend fun collectFreshDevices(): List<BlePodSnapshot> {
// Drop anything cached under a previous combo so it can't satisfy this attempt.
blePodMonitor.clearDeviceCache()
val freshThreshold = timeSource.now().plusMillis(SCAN_SETTLE_MS)
val start = timeSource.elapsedRealtime()
val collected = mutableListOf<BlePodSnapshot>()
// Accumulate as we go: a timeout must not discard what we already observed. toList() only
// returns once the flow completes, which a quiet channel may never do within the window.
withTimeoutOrNull(STEP_TIME) {
blePodMonitor.devices
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 }
.collect { snapshots -> collected.addAll(snapshots) }
}
return collected
.filter { it.seenLastAt >= freshThreshold }
.distinctBy { it.address }
}
private fun persistCompat(combo: BlePodMonitor.CompatOverride) {
generalSettings.isOffloadedFilteringDisabled.valueBlocking = combo.offloadedFilteringDisabled
generalSettings.isOffloadedBatchingDisabled.valueBlocking = combo.offloadedBatchingDisabled
generalSettings.useIndirectScanResultCallback.valueBlocking = combo.indirectCallback
}
sealed class BleState {
class Intro : BleState()
@@ -295,6 +313,29 @@ class TroubleShooterViewModel @Inject constructor(
companion object {
const val STEP_TIME = 10 * 1000L
/**
* Grace period after switching compat settings before an observation counts as "fresh".
* Covers [BlePodMonitor]'s ~1s scan-option throttle plus the scanner restart.
*/
const val SCAN_SETTLE_MS = 1500L
/**
* Compatibility combinations to probe, ordered fewest-disables-first so the first one that
* works (and gets persisted) is the *minimal* set of overrides — e.g. a phone that only
* needs batching disabled won't also get filtering disabled. Triple semantics:
* (offloadedFilteringDisabled, offloadedBatchingDisabled, indirectCallback).
*/
val COMPAT_COMBOS: List<BlePodMonitor.CompatOverride> = listOf(
BlePodMonitor.CompatOverride(false, false, false), // baseline (no overrides)
BlePodMonitor.CompatOverride(false, true, false), // batching only
BlePodMonitor.CompatOverride(true, false, false), // filtering only
BlePodMonitor.CompatOverride(false, false, true), // indirect callback only
BlePodMonitor.CompatOverride(false, true, true), // batching + indirect
BlePodMonitor.CompatOverride(true, false, true), // filtering + indirect
BlePodMonitor.CompatOverride(true, true, false), // filtering + batching
BlePodMonitor.CompatOverride(true, true, true), // everything
)
val TAG = logTag("TroubleShooter", "VM")
}
}
+1 -1
View File
@@ -506,7 +506,7 @@
<string name="device_settings_category_connections_label">Gekoppelde toestelle</string>
<string name="device_settings_connected_devices_description">Ander toestelle wat tans aan hierdie AirPods gekoppel is</string>
<string name="device_settings_connected_device_label">Toestel %d</string>
<string name="device_settings_connected_device_call">In \n oproep</string>
<string name="device_settings_connected_device_call">In n oproep</string>
<string name="device_settings_connected_device_media">Speel media</string>
<string name="device_settings_noise_control_label">Geraasbeheer</string>
<string name="device_settings_eq_label">Egaliseerder</string>
+12 -12
View File
@@ -28,12 +28,12 @@
<string name="upgrade_benefit_device_settings">إعدادات الجهاز المتقدّمة</string>
<string name="upgrade_benefit_device_controls">عناصر التحكم في الجهاز &#8212; الضوضاء، &amp; السيقان، &amp; المزيد</string>
<string name="upgrade_benefit_support">دعم المطوّر</string>
<string name="upgrade_benefit_disclaimer">يعتمد توفر الميزات على سماعات الرأس والجهاز المستخدَم.</string>
<string name="upgrade_benefit_disclaimer">يعتمد توفر الميزات على سمّاعات الرأس والجهاز المستخدَم.</string>
<string name="upgrade_screen_options_description">نفس الميزات، أسعار مختلفة. يشمل الاشتراك فترة تجريبية مجانية ويمكن إلغاؤه في أي وقت.</string>
<string name="upgrade_screen_subscription_trial_action">بدء التجربة المجانية</string>
<string name="upgrade_screen_subscription_action">الاشتراك سنويًا</string>
<string name="upgrade_screen_subscription_action_hint">%s/سنة</string>
<string name="upgrade_screen_iap_action">اشترِ مرة واحدة</string>
<string name="upgrade_screen_iap_action">الشراء مرة واحدة</string>
<string name="upgrade_screen_iap_action_hint">الشراء لمرة واحدة: %s</string>
<string name="upgrade_screen_restore_purchase_action">استعادة المشتريات</string>
<string name="upgrade_screen_restore_purchase_message">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
@@ -44,18 +44,18 @@
<string name="settings_monitor_connected_notification_description">يعرض إشعارًا إضافيًا عند توصيل جهاز. يتيح لك هذا إخفاء الإشعار الدائم \"لا توجد أجهزة\" عن طريق تعطيل قناة \"حالة الجهاز\".</string>
<string name="settings_keep_notification_after_disconnect_label">الاحتفاظ بالإشعار بعد قطع الاتصال</string>
<string name="settings_keep_notification_after_disconnect_description">الاستمرار في عرض مستويات البطارية المعروفة الأخيرة حتى بعد قطع اتصال AirPods</string>
<string name="settings_autopause_label">إيقاف مؤقت تلقائي</string>
<string name="settings_autopause_description">إيقاف الصوت مؤقتًا عند إزالة الجهاز من أذنك.</string>
<string name="settings_autopause_label">إيقاف مؤقّت تلقائي</string>
<string name="settings_autopause_description">إيقاف الصوت مؤقّتًا عند إزالة الجهاز من أذنك.</string>
<string name="settings_autopplay_label">تشغيل تلقائي</string>
<string name="settings_autoplay_description">استئناف الموسيقى عند ارتداء السماعات مجددًا، ولكن فقط إذا أوقفها الإيقاف التلقائي. الإيقافات التي قمت بتشغيلها بنفسك (الهاتف، ذراع AirPods، وضع السكون) تبقى متوقفة.</string>
<string name="settings_autoplay_description">استئناف الموسيقى عند ارتداء السماعات مجدّدًا، ولكن فقط إذا أوقفها الإيقاف التلقائي. الإيقافات التي شغّلتها بنفسك (الهاتف، ذراع AirPods، وضع السكون) تبقى متوقّفة.</string>
<string name="settings_start_music_on_wear_label">تشغيل الموسيقى عند الارتداء</string>
<string name="settings_start_music_on_wear_description">يحاول دائمًا تشغيل الموسيقى عند وضع السماعات، حتى بعد الإيقاف اليدوي.</string>
<string name="settings_start_music_on_wear_description">يحاول دائمًا تشغيل الموسيقى عند وضع السمّاعات، حتى بعد الإيقاف اليدوي.</string>
<string name="settings_eardetection_info_label">ملاحظة اكتشاف الأذن</string>
<string name="settings_eardetection_info_description">إذا كان اكتشاف الأذن يعمل لسماعة واحدة فقط، فهذا قيد من Apple. يتم اكتشاف \"السماعة الأساسية\" فقط (المستخدمة للميكروفون). قم بالتكوين على أجهزة Apple: الإعدادات → Bluetooth → AirPods → الميكروفون.</string>
<string name="settings_eardetection_info_description">إذا كان اكتشاف الأذن يعمل لسماعة واحدة فقط، فهذا قيدٌ من Apple. يتم اكتشاف \"السمّاعة الأساسية\" فقط (المستخدَمة للميكروفون). اضبط على أجهزة Apple: الإعدادات → Bluetooth → AirPods → الميكروفون.</string>
<string name="settings_signal_minimum_label">الحد الأدنى لجودة الإشارة</string>
<string name="settings_signal_minimum_description">الحد الأدنى لجودة الإشارة التي يجب أن يتمتع بها الجهاز حتى يعتبر ملكك.</string>
<string name="settings_signal_minimum_description">الحد الأدنى لجودة الإشارة التي يجب أن يتمتّع بها الجهاز حتى يُعدّ ملكك.</string>
<string name="settings_autoconnect_label">اتصال تلقائي</string>
<string name="settings_autoconnect_description">إذا لم يتصل Android تلقائيًا، فيمكننا أن نطلب منه ذلك أيضًا. سيؤدي هذا إلى ضبط إعداد وضع الشاشة على \"دائمًا\".</string>
<string name="settings_autoconnect_description">إذا لم يتّصل Android تلقائيًا، فيمكننا أن نطلب منه ذلك أيضًا. سيؤدي هذا إلى ضبط إعداد وضع الشاشة على \"دائمًا\".</string>
<string name="settings_autoconnect_info_android12">يعتمد الاتصال التلقائي على ميزة نظام يقيّدها Android 12 والإصدارات الأحدث. قد لا تعمل على جهازك.</string>
<string name="settings_autoconnect_condition_label">شرط الاتصال التلقائي</string>
<string name="settings_autoconnect_condition_description">متى يجب أن نحاول الاتصال بجهازك؟</string>
@@ -127,14 +127,14 @@
<string name="anc_widget_config_aap_required_hint">يتطلب هذا الودجت اتصالاً مباشراً (AAP) بجهازك. تأكد من إقران جهازك وتوصيله في التطبيق قبل استخدام هذا الودجت.</string>
<string name="anc_widget_no_anc_support_label">هذا الجهاز لا يدعم التحكم في الضوضاء</string>
<string name="anc_widget_aap_not_connected_label">غير متصل</string>
<string name="anc_widget_aap_not_connected_nearby_description">قريب ولكن غير متصل</string>
<string name="anc_widget_aap_not_connected_nearby_description">قريب ولكن غير متّصل</string>
<string name="anc_widget_aap_not_connected_not_nearby_description">غير قريب</string>
<string name="anc_widget_aap_connecting_label">جارٍ الاتصال…</string>
<string name="tile_anc_label">التحكم في الضوضاء</string>
<string name="tile_anc_subtitle_permission_required">الإذن مطلوب</string>
<string name="tile_anc_subtitle_no_device">لا يوجد جهاز</string>
<string name="tile_anc_subtitle_no_anc_support">لا يوجد تحكم في الضوضاء</string>
<string name="tile_anc_subtitle_bluetooth_off">البلوتوث مُغلق</string>
<string name="tile_anc_subtitle_bluetooth_off">البلوتوث مغلق</string>
<string name="widget_config_screen_title">إعداد عنصر الواجهة</string>
<string name="widget_configuration_title">اختيار الجهاز</string>
<string name="widget_configuration_description">اختر ملف تعريف الجهاز الذي يجب أن تعرضه هذه الأداة.</string>
@@ -488,7 +488,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">عام</string>
<string name="device_settings_microphone_mode_label">ميكروفون</string>
<string name="device_settings_microphone_mode_description">سماعة الأذن المستخدمة كميكروفون</string>
<string name="device_settings_microphone_mode_description">سمّاعة الأذن المستخدمة كميكروفون</string>
<string name="device_settings_microphone_mode_auto">تلقائي</string>
<string name="device_settings_microphone_mode_right">يمين</string>
<string name="device_settings_microphone_mode_left">يسار</string>
+5 -5
View File
@@ -47,9 +47,9 @@
<string name="settings_autopause_label">Pausa automàtica</string>
<string name="settings_autopause_description">Pausa l\'àudio quan ús tragueu el dispositiu de l\'orella.</string>
<string name="settings_autopplay_label">Reproducció automàtica</string>
<string name="settings_autoplay_description">Repren la música quan tornes a posar-te els auriculars, però només si la pausa automàtica l\'havia aturat. Les pauses que has activat tu mateix (telèfon, pal dels AirPods, son) romanen en pausa.</string>
<string name="settings_start_music_on_wear_label">Inicia la música en posar-se els auriculars</string>
<string name="settings_start_music_on_wear_description">Sempre intenta reproduir música quan et poses els auriculars, fins i tot després d\'una pausa manual.</string>
<string name="settings_autoplay_description">Reprèn la música quan torneu a posar-vos els auriculars, però només si la pausa automàtica l\'havia aturat. Les pauses que heu activat vos mateix (telèfon, pliques dels AirPods, son) romanen en pausa.</string>
<string name="settings_start_music_on_wear_label">Inicia la música en posar-vos els auriculars</string>
<string name="settings_start_music_on_wear_description">Sempre intenta reproduir música quan us poseu els auriculars, fins i tot després d\'una pausa manual.</string>
<string name="settings_eardetection_info_label">Nota de detecció de l\'oïda</string>
<string name="settings_eardetection_info_description">Si la detecció de l\'oïda només funciona per a un auricular, això és degut a una limitació d\'Apple. Només es detecta «l\'auricular principal» (utilitzat per al micròfon). Configureu-ho en dispositius Apple: Configuració → Bluetooth → AirPods → Micròfon.</string>
<string name="settings_signal_minimum_label">Qualitat de senyal mínima</string>
@@ -248,8 +248,8 @@
<string name="anc_mode_transparency">Transparència</string>
<string name="anc_mode_adaptive">Adaptatiu</string>
<string name="conversation_awareness_label">Consciència de conversa</string>
<string name="settings_conversation_action_label">Quan comences a parlar</string>
<string name="settings_conversation_action_nothing_label">No facis res</string>
<string name="settings_conversation_action_label">Quan comenceu a parlar</string>
<string name="settings_conversation_action_nothing_label">No fer res</string>
<string name="settings_conversation_action_lower_volume_label">Baixa el volum</string>
<string name="settings_conversation_action_pause_label">Posa en pausa el reproductor</string>
<string name="settings_conversation_volume_reduction_label">Reducció del volum</string>
+8 -8
View File
@@ -47,9 +47,9 @@
<string name="settings_autopause_label">Automaatne peatamine</string>
<string name="settings_autopause_description">Seadme kõrvast eemaldamisel peata esitamine.</string>
<string name="settings_autopplay_label">Automaatne esitamine</string>
<string name="settings_autoplay_description">Jätka muusika esitust, kui kõrvaklapid jälle seljas, kuid ainult siis, kui Auto-paus selle peatas. Pausid, mille tegid ise (telefon, AirPodsi vars, uni), jäävad pausi peale.</string>
<string name="settings_start_music_on_wear_label">Käivita muusika kandmisel</string>
<string name="settings_start_music_on_wear_description">Proovib alati mängida muusikat, kui paned kõrvaklapid pähe, isegi pärast käsitsi pausi.</string>
<string name="settings_autoplay_description">Jätka muusika kuulamist, kui kannad taas kõrvaklappe, kuid ainult siis, kui isekäivitunud paus selle peatas. Isepausimise (telefon, AirPodsi vars, uni) puhul jääb paus kestma.</string>
<string name="settings_start_music_on_wear_label">Esita muusikat kõrva panemisel</string>
<string name="settings_start_music_on_wear_description">Proovib alati esitada muusikat, kui paned kõrvaklapid pähe, isegi pärast enda käivitatud pausi.</string>
<string name="settings_eardetection_info_label">Kõrva tuvastamisest</string>
<string name="settings_eardetection_info_description">Kui tuvastatakse vaid üks klapp, siis seda põhjustab Apple\'i piirang. Tuvastatakse vaid peamine klapp (kasutatakse mikrofonina). Seadista Apple\'i seadmetes: Seaded (Settings) → Bluetooth → AirPods (AirPodid) → Mikrofon (Microphone).</string>
<string name="settings_signal_minimum_label">Väikseim signaali kvaliteet</string>
@@ -127,13 +127,13 @@
<string name="anc_widget_config_aap_required_hint">See vidin nõuab otsest ühendust (AAP) teie seadmega. Enne selle vidina kasutamist veenduge, et teie seade on rakenduses seotud ja ühendatud.</string>
<string name="anc_widget_no_anc_support_label">See seade ei toeta mürakontrolli</string>
<string name="anc_widget_aap_not_connected_label">Ühendus puudub</string>
<string name="anc_widget_aap_not_connected_nearby_description">Lähedal, kuid ei ole ühendatud</string>
<string name="anc_widget_aap_not_connected_nearby_description">Lähedal, kuid pole ühendatud</string>
<string name="anc_widget_aap_not_connected_not_nearby_description">Pole lähedal</string>
<string name="anc_widget_aap_connecting_label">Ühendamine…</string>
<string name="tile_anc_label">Mürakontroll</string>
<string name="tile_anc_subtitle_permission_required">Luba nõutud</string>
<string name="tile_anc_subtitle_permission_required">Vaja on luba</string>
<string name="tile_anc_subtitle_no_device">Puudub seade</string>
<string name="tile_anc_subtitle_no_anc_support">Mürakontroll puudub</string>
<string name="tile_anc_subtitle_no_anc_support">Puudub mürakontroll</string>
<string name="tile_anc_subtitle_bluetooth_off">Bluetooth väljas</string>
<string name="widget_config_screen_title">Vidina seadistus</string>
<string name="widget_configuration_title">Vali seade</string>
@@ -250,7 +250,7 @@
<string name="conversation_awareness_label">Vestluse tuvastamine</string>
<string name="settings_conversation_action_label">Kui alustad rääkimist</string>
<string name="settings_conversation_action_nothing_label">Ära tee midagi</string>
<string name="settings_conversation_action_lower_volume_label">Alanda helitugevust</string>
<string name="settings_conversation_action_lower_volume_label">Vähenda helitugevust</string>
<string name="settings_conversation_action_pause_label">Peata meedia</string>
<string name="settings_conversation_volume_reduction_label">Helitugevuse vähendamine</string>
<string name="signal_badge_ble_cd">BLE skannimine aktiivne</string>
@@ -468,7 +468,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">Üldine</string>
<string name="device_settings_microphone_mode_label">Mikrofon</string>
<string name="device_settings_microphone_mode_description">Millist kõrvaklappi kasutatakse mikrofonina</string>
<string name="device_settings_microphone_mode_description">Kumba kõrvaklappi kasutatakse mikrofonina</string>
<string name="device_settings_microphone_mode_auto">Automaatne</string>
<string name="device_settings_microphone_mode_right">Parem</string>
<string name="device_settings_microphone_mode_left">Vasak</string>
+4 -4
View File
@@ -47,9 +47,9 @@
<string name="settings_autopause_label">Автопауза</string>
<string name="settings_autopause_description">Приостановка аудио при удалении устройства из уха.</string>
<string name="settings_autopplay_label">Автовоспроизведение</string>
<string name="settings_autoplay_description">Возобновляет музыку, когда вы снова надеваете наушники, но только если воспроизведение остановила функция «Авто-пауза». Паузы, которые вы поставили вручную (с телефона, кнопки AirPods или по таймеру сна), сохраняются.</string>
<string name="settings_autoplay_description">Возобновите музыку, когда снова надеваете наушники, но только если воспроизведение было остановлено функцией «Автопауза». Паузы, которые Вы поставили вручную (с телефона, AirPods или по таймеру сна), сохраняются.</string>
<string name="settings_start_music_on_wear_label">Запускать музыку при надевании</string>
<string name="settings_start_music_on_wear_description">Всегда пытается воспроизвести музыку, когда вы надеваете наушники, даже после ручной паузы.</string>
<string name="settings_start_music_on_wear_description">Всегда пытается воспроизвести музыку, когда Вы надеваете наушники, даже после ручной паузы.</string>
<string name="settings_eardetection_info_label">Уведомление об обнаружении уха</string>
<string name="settings_eardetection_info_description">Если обнаружение уха работает только для одного наушника, это ограничение Apple. Обнаруживается только \"основной наушник\" (используемый для микрофона). Настроить на устройствах Apple: Настройки → Bluetooth → AirPods → Микрофон.</string>
<string name="settings_signal_minimum_label">Минимальное качество сигнала</string>
@@ -253,10 +253,10 @@ AL_Cool_T</string>
<string name="anc_mode_transparency">Прозрачность</string>
<string name="anc_mode_adaptive">Адаптивный</string>
<string name="conversation_awareness_label">Распознавание голоса в разговоре</string>
<string name="settings_conversation_action_label">Когда вы начинаете говорить</string>
<string name="settings_conversation_action_label">Когда Вы начинаете говорить</string>
<string name="settings_conversation_action_nothing_label">Ничего не делать</string>
<string name="settings_conversation_action_lower_volume_label">Уменьшить громкость</string>
<string name="settings_conversation_action_pause_label">Пауза медиа</string>
<string name="settings_conversation_action_pause_label">Приостановить медиа</string>
<string name="settings_conversation_volume_reduction_label">Снижение громкости</string>
<string name="signal_badge_ble_cd">BLE сканирование активно</string>
<string name="signal_badge_key_irk_cd">Личность подтверждена</string>
+6
View File
@@ -127,6 +127,9 @@
<string name="settings_category_debug_label">Debug</string>
<string name="settings_general_label">Settings</string>
<string name="settings_general_description">General tweaks that affect the whole app.</string>
<string name="settings_overview_hide_unmatched_label">Hide unmatched devices</string>
<string name="settings_overview_hide_unmatched_description">Don\'t show nearby devices that don\'t match any of your profiles in the overview, e.g. other people\'s AirPods.</string>
<string name="settings_acknowledgements_label">Acknowledgements</string>
<string name="settings_debug_autoreports_label">Automatic bug reports</string>
@@ -219,6 +222,9 @@
<string name="overview_bluetooth_disabled_description">Bluetooth is disabled, enable it ;)</string>
<string name="overview_monitoring_active_label">Monitoring for devices</string>
<string name="overview_monitoring_active_description">Make sure your device is nearby and active.</string>
<string name="overview_troubleshoot_suggestion_label">Connected, but no data</string>
<string name="overview_troubleshoot_suggestion_description">Your device is connected, but CAPod isn\'t receiving any live data from it. Your phone may need a compatibility option — the troubleshooter can try to find one automatically.</string>
<string name="overview_troubleshoot_suggestion_action">Run troubleshooter</string>
<string name="overview_unmatched_devices_label">Unmatched devices</string>
<plurals name="overview_unmatched_devices_count">
<item quantity="one">%d device without matching profile</item>
@@ -13,6 +13,8 @@ import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
@@ -25,6 +27,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
@@ -64,6 +67,7 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue<Boolean>
@BeforeEach
fun setup() {
@@ -78,6 +82,7 @@ class OverviewViewModelTest : BaseTest() {
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(relaxed = true))
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
fakeReactionsHintDismissed = FakeDataStoreValue(false)
fakeHideUnmatchedDevices = FakeDataStoreValue(false)
Bugs.isDebug.value = false
monitorControl = mockk(relaxed = true)
@@ -95,6 +100,7 @@ class OverviewViewModelTest : BaseTest() {
generalSettings = mockk<GeneralSettings>().also {
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock
}
monitorModeResolver = mockk<MonitorModeResolver>().also {
@@ -341,6 +347,68 @@ class OverviewViewModelTest : BaseTest() {
state.visibleProfiledDevices shouldBe listOf(profiled1)
state.hiddenProfiledDeviceCount shouldBe 1
}
@Test
fun `hideUnmatchedDevices defaults to false`() = runTest(testDispatcher) {
val vm = createViewModel()
val state = vm.state.first()
state.hideUnmatchedDevices shouldBe false
}
@Test
fun `enabling hideUnmatchedDevices setting propagates to state`() = runTest(testDispatcher) {
fakeHideUnmatchedDevices.value = true
val vm = createViewModel()
val state = vm.state.first()
state.hideUnmatchedDevices shouldBe true
}
@Test
fun `hideUnmatchedDevices true hides the unmatched section`() {
val unmatched = PodDevice(profileId = null, ble = mockk(relaxed = true), aap = null)
val shown = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = emptySet(),
devices = listOf(unmatched),
isDebug = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
hideUnmatchedDevices = false,
)
shown.visibleUnmatchedDevices shouldBe listOf(unmatched)
shown.shouldShowUnmatchedSection shouldBe true
val hidden = shown.copy(hideUnmatchedDevices = true)
hidden.visibleUnmatchedDevices shouldBe emptyList()
hidden.shouldShowUnmatchedSection shouldBe false
}
@Test
fun `only hidden unmatched devices present - no visible content`() {
// Guards the dashboard blank-state: when only hidden unmatched devices are nearby there
// are no profiled devices AND no visible unmatched section, so the screen shows the
// "monitoring active" card instead of an empty list.
val unmatched = PodDevice(profileId = null, ble = mockk(relaxed = true), aap = null)
val state = OverviewViewModel.State(
now = java.time.Instant.now(),
permissions = emptySet(),
devices = listOf(unmatched),
isDebug = false,
isBluetoothEnabled = true,
profiles = emptyList(),
upgradeInfo = mockk(relaxed = true),
showUnmatchedDevices = false,
hideUnmatchedDevices = true,
)
state.profiledDevices shouldBe emptyList()
state.shouldShowUnmatchedSection shouldBe false
}
}
@Nested
@@ -489,4 +557,86 @@ class OverviewViewModelTest : BaseTest() {
event shouldBe Permission.BLUETOOTH
}
}
@Nested
inner class TroubleshootSuggestionTests {
private val connectedAddress = "AA:BB:CC:DD:EE:FF"
private fun connectedProfile(): DeviceProfile = AppleDeviceProfile(
label = "Test",
model = PodModel.AIRPODS_PRO2,
address = connectedAddress,
)
private fun connectedBtDevice() = mockk<BluetoothDevice2>(relaxed = true) {
every { address } returns connectedAddress
}
private fun setConnectedButNoLiveData() {
profilesFlow.value = listOf(connectedProfile())
connectedDevicesFlow.value = listOf(connectedBtDevice())
devicesFlow.value = emptyList()
}
@Test
fun `appears only after the debounce delay`() = runTest(testDispatcher) {
setConnectedButNoLiveData()
val vm = createViewModel()
var latest: OverviewViewModel.State? = null
backgroundScope.launch { vm.state.collect { latest = it } }
advanceTimeBy(14_000)
latest!!.showTroubleshootSuggestion shouldBe false
advanceTimeBy(2_000)
latest!!.showTroubleshootSuggestion shouldBe true
}
@Test
fun `suppressed while any pod is live even after the delay`() = runTest(testDispatcher) {
// #603 duplicate state: broadcasts ARE arriving (a live pod exists), so the card must
// not claim "no data" even though a profiled card may momentarily read as non-live.
profilesFlow.value = listOf(connectedProfile())
connectedDevicesFlow.value = listOf(connectedBtDevice())
devicesFlow.value = listOf(PodDevice(profileId = null, ble = mockk(relaxed = true), aap = null))
val vm = createViewModel()
var latest: OverviewViewModel.State? = null
backgroundScope.launch { vm.state.collect { latest = it } }
advanceTimeBy(20_000)
latest!!.showTroubleshootSuggestion shouldBe false
}
@Test
fun `not shown when no profile is system-connected`() = runTest(testDispatcher) {
profilesFlow.value = listOf(connectedProfile())
connectedDevicesFlow.value = emptyList()
devicesFlow.value = emptyList()
val vm = createViewModel()
var latest: OverviewViewModel.State? = null
backgroundScope.launch { vm.state.collect { latest = it } }
advanceTimeBy(20_000)
latest!!.showTroubleshootSuggestion shouldBe false
}
@Test
fun `hides immediately when live data returns before the delay`() = runTest(testDispatcher) {
setConnectedButNoLiveData()
val vm = createViewModel()
var latest: OverviewViewModel.State? = null
backgroundScope.launch { vm.state.collect { latest = it } }
advanceTimeBy(10_000)
latest!!.showTroubleshootSuggestion shouldBe false
// A broadcast arrives before the 15s window elapses — the pending suggestion is cancelled.
devicesFlow.value = listOf(PodDevice(profileId = null, ble = mockk(relaxed = true), aap = null))
advanceTimeBy(10_000)
latest!!.showTroubleshootSuggestion shouldBe false
}
}
}
@@ -529,16 +529,21 @@ class AapSessionEngineTest : BaseTest() {
}
@Test
fun `status 6, 8, 9 emit STOP`() = runTest(UnconfinedTestDispatcher()) {
fun `terminal statuses 5, 6, 8, 9 emit STOP`() = runTest(UnconfinedTestDispatcher()) {
// 5 is the terminal wind-down value on fw …6861 (never reaches 6/8/9); 6/8/9 on fw …6503.
firstEventFor(5) shouldBe ConversationAwarenessEvent.STOP
firstEventFor(6) shouldBe ConversationAwarenessEvent.STOP
firstEventFor(8) shouldBe ConversationAwarenessEvent.STOP
firstEventFor(9) shouldBe ConversationAwarenessEvent.STOP
}
@Test
fun `intermediate status emits HOLD (keep-alive)`() = runTest(UnconfinedTestDispatcher()) {
fun `transitional and unknown statuses emit HOLD (stay engaged)`() = runTest(UnconfinedTestDispatcher()) {
// Must never disengage on these — only an explicit terminal STOP does.
firstEventFor(3) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(4) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(0x0B) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(7) shouldBe ConversationAwarenessEvent.HOLD
}
}
@@ -7,6 +7,7 @@ import eu.darken.capod.pods.core.apple.ble.devices.airpods.HasStateDetectionAirP
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.time.Instant
@@ -250,13 +251,34 @@ class DualApplePodsTest : BaseBlePodsTest() {
@Test
fun `test AirPodDevice - case lid uses status derived case context`() {
directAirPodsPro(status = 0x10, rawCaseLidState = 0x51).caseLidState shouldBe DualApplePods.LidState.OPEN
// bit4 only = one pod in case, broadcast by the OUT-of-case pod. Its lid byte is stale and
// decodes to a phantom OPEN even when the case is shut, so it must report UNKNOWN (#598).
directAirPodsPro(status = 0x10, rawCaseLidState = 0x51).caseLidState shouldBe DualApplePods.LidState.UNKNOWN
// bit2 = both pods in case → lid byte is trustworthy.
directAirPodsPro(status = 0x04, rawCaseLidState = 0x5A).caseLidState shouldBe DualApplePods.LidState.CLOSED
// bit6 = this (broadcasting) pod is in the case → lid byte is trustworthy.
directAirPodsPro(status = 0x40, rawCaseLidState = 0x51).caseLidState shouldBe DualApplePods.LidState.OPEN
// No case context at all → NOT_IN_CASE.
directAirPodsPro(status = 0x2B, rawCaseLidState = 0x11).caseLidState shouldBe DualApplePods.LidState.NOT_IN_CASE
directAirPodsPro(status = 0x20, rawCaseLidState = 0x5A).caseLidState shouldBe DualApplePods.LidState.NOT_IN_CASE
}
@Test
fun `case lid - out-of-case pod frame reports UNKNOWN, not a phantom OPEN (issue 598)`() {
// Real captures while the case is physically shut with one pod removed. The in-case pod
// reports CLOSED; the out-of-case (bit4-only) pod carries a stale lid byte that the old
// decoder turned into a phantom OPEN. It must now be UNKNOWN.
// AirPods Pro 3:
directAirPodsPro(status = 0x73, rawCaseLidState = 0x39).caseLidState shouldBe DualApplePods.LidState.CLOSED
directAirPodsPro(status = 0x13, rawCaseLidState = 0x11).caseLidState shouldBe DualApplePods.LidState.UNKNOWN
// AirPods Pro 1:
directAirPodsPro(status = 0x53, rawCaseLidState = 0x39).caseLidState shouldBe DualApplePods.LidState.CLOSED
directAirPodsPro(status = 0x33, rawCaseLidState = 0x02).caseLidState shouldBe DualApplePods.LidState.UNKNOWN
// Both pods in the case (bit2) stays trustworthy even without bit6 (Pro 3 0x15, Pro 1 0x04).
directAirPodsPro(status = 0x15, rawCaseLidState = 0x31).caseLidState shouldBe DualApplePods.LidState.OPEN
directAirPodsPro(status = 0x04, rawCaseLidState = 0x31).caseLidState shouldBe DualApplePods.LidState.OPEN
}
private fun knownDeviceOf(vararg pods: AirPodsPro): KnownDevice {
val id = pods.first().identifier
return KnownDevice(
@@ -315,6 +337,47 @@ class DualApplePodsTest : BaseBlePodsTest() {
result shouldBe DualApplePods.LidState.NOT_IN_CASE
}
@Test
fun `getLatestCaseLidState - phantom out-of-case frame recovers CLOSED from in-case history (issue 598)`() {
val sharedId = BlePodSnapshot.Id()
// In-case pod reports the real, shut lid; out-of-case pod's bit4-only frame is UNKNOWN.
val inCaseClosed = directAirPodsPro(status = 0x73, rawCaseLidState = 0x39).copy(identifier = sharedId)
val outOfCasePhantom = directAirPodsPro(status = 0x13, rawCaseLidState = 0x11).copy(identifier = sharedId)
val known = knownDeviceOf(inCaseClosed, outOfCasePhantom)
val result = with(testFactory) { known.getLatestCaseLidState(outOfCasePhantom) }
// Must recover CLOSED from the in-case broadcast, not surface the phantom OPEN.
result shouldBe DualApplePods.LidState.CLOSED
}
@Test
fun `getLatestCaseLidState - only out-of-case frames never report a phantom OPEN (issue 598)`() {
val sharedId = BlePodSnapshot.Id()
val phantom1 = directAirPodsPro(status = 0x13, rawCaseLidState = 0x11).copy(identifier = sharedId)
val phantom2 = directAirPodsPro(status = 0x13, rawCaseLidState = 0x11).copy(identifier = sharedId)
val known = knownDeviceOf(phantom1, phantom2)
val result = with(testFactory) { known.getLatestCaseLidState(phantom2) }
// No authoritative reading anywhere → fall back to a coarse signal, never a guessed OPEN.
result shouldBe DualApplePods.LidState.NOT_IN_CASE
}
@Test
fun `case lid - first-seen out-of-case frame does not leak a phantom OPEN through the factory (issue 598)`() = runTest {
// Going through the real factory, a bit4-only out-of-case frame must never surface a phantom
// OPEN. The exact non-OPEN value depends on whether the device already has history
// (UNKNOWN when first-seen, NOT_IN_CASE once a single phantom frame is in history) — both are
// acceptable; what matters is that it is never OPEN.
create<DualApplePods>(
hex = "07 19 01 0E 20 13 AA B5 11 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4",
address = "AA:BB:CC:DD:EE:01",
) {
caseLidState shouldNotBe DualApplePods.LidState.OPEN
}
}
@Test
fun `test AirPodDevice - connection state`() = runTest {
// Disconnected
@@ -31,8 +31,9 @@ class ConversationReactionTest : BaseTest() {
private val primaryAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
private val otherAddress: BluetoothAddress = "11:22:33:44:55:66"
// Mirror of ConversationReaction.STALE_TIMEOUT_MS (private there).
private val staleTimeoutMs = 12_000L
// Mirror of ConversationReaction.STALE_TIMEOUT (private there) — a long backstop, not the
// normal disengage path (which is the explicit STOP frame).
private val staleTimeoutMs = 5L * 60 * 1000
private lateinit var eventsFlow: MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>
private lateinit var statesFlow: MutableStateFlow<Map<BluetoothAddress, AapPodState>>
@@ -148,18 +149,18 @@ class ConversationReactionTest : BaseTest() {
}
@Test
fun `HOLD keep-alive resets the stale timer`() = runTest(UnconfinedTestDispatcher()) {
fun `HOLD keep-alive resets the stale backstop`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
advanceTimeBy(8_000)
advanceTimeBy(staleTimeoutMs * 7 / 10)
runCurrent()
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // resets the timer
advanceTimeBy(8_000) // 8s since the HOLD — still within the window
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // resets the backstop
advanceTimeBy(staleTimeoutMs * 7 / 10) // <1 backstop since the HOLD — still engaged
runCurrent()
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
advanceTimeBy(5_000) // now >12s since the last frame
advanceTimeBy(staleTimeoutMs / 2) // now >1 backstop since the last frame
runCurrent()
verify(exactly = 1) { mediaControl.restoreMusicVolume(10) }
job.cancel()
@@ -272,4 +273,73 @@ class ConversationReactionTest : BaseTest() {
verify(exactly = 1) { mediaControl.duckMusicVolume(any()) }
job.cancel()
}
@Test
fun `PAUSE stays paused through frame silence, resumes only on explicit STOP, then re-engages`() =
runTest(UnconfinedTestDispatcher()) {
// Regression for the fw …6861 bug: the pod sends an onset, then NO frames for ~20s while
// the wearer keeps talking, then a terminal STOP. The old 12s stale timeout resumed media
// mid-speech; the backstop must not, and a fresh talk must re-arm.
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.START) // status 1 then 2
coVerify(exactly = 1) { mediaControl.sendPause(false) }
advanceTimeBy(20_000) // 20s of silence — well under the backstop
runCurrent()
coVerify(exactly = 0) { mediaControl.sendPlay() } // NOT resumed mid-speech
emit(primaryAddress, ConversationAwarenessEvent.STOP) // wearer stopped → pod's terminal frame
coVerify(exactly = 1) { mediaControl.sendPlay() }
emit(primaryAddress, ConversationAwarenessEvent.START) // a fresh talk re-arms
coVerify(exactly = 2) { mediaControl.sendPause(false) }
job.cancel()
}
@Test
fun `HOLD frames keep media paused, only terminal STOP resumes`() = runTest(UnconfinedTestDispatcher()) {
// fw …6503-style wind-down 1,2,3,0xB,4,8,9: transitional frames must not resume.
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 3
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 0x0B
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // 4
coVerify(exactly = 0) { mediaControl.sendPlay() }
emit(primaryAddress, ConversationAwarenessEvent.STOP) // 8
coVerify(exactly = 1) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `STOP from a non-owner does not disengage the active owner`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(otherAddress, ConversationAwarenessEvent.STOP) // a different device's STOP
coVerify(exactly = 0) { mediaControl.sendPlay() }
job.cancel()
}
@Test
fun `explicit STOP then stale backstop does not double-resume`() = runTest(UnconfinedTestDispatcher()) {
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START)
emit(primaryAddress, ConversationAwarenessEvent.STOP)
coVerify(exactly = 1) { mediaControl.sendPlay() }
advanceTimeBy(staleTimeoutMs + 500) // backstop would fire if STOP hadn't cancelled it
runCurrent()
coVerify(exactly = 1) { mediaControl.sendPlay() } // still only once
job.cancel()
}
}
@@ -1,7 +1,10 @@
package eu.darken.capod.reaction.core.popup
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
@@ -189,4 +192,103 @@ class PopUpReactionLogicTest : BaseTest() {
).shouldShow shouldBe false
}
}
@Nested
inner class CooldownGuardTests {
private val timeSource = TestTimeSource(wallNow = Instant.parse("2026-01-01T00:00:00Z"))
private val reaction = PopUpReaction(
deviceMonitor = mockk(relaxed = true),
bluetoothManager = mockk(relaxed = true),
timeSource = timeSource,
)
@Test
fun `UNKNOWN hide does not refresh the show cooldown (issue 598)`() {
// Show on OPEN → cooldown stamped now.
reaction.throttleCasePopUps(device(DualApplePods.LidState.OPEN))
.shouldBeInstanceOf<PopUpReaction.Event.PopupShow>()
// Long past the 10s cooldown, a transient out-of-case frame hides the popup...
timeSource.advanceBy(Duration.ofSeconds(11))
reaction.throttleCasePopUps(device(DualApplePods.LidState.UNKNOWN))
.shouldBeInstanceOf<PopUpReaction.Event.PopupHide>()
// ...and must NOT have refreshed the cooldown: a genuine OPEN 1s later still shows.
timeSource.advanceBy(Duration.ofSeconds(1))
reaction.throttleCasePopUps(device(DualApplePods.LidState.OPEN))
.shouldBeInstanceOf<PopUpReaction.Event.PopupShow>()
}
@Test
fun `OPEN within cooldown is still throttled`() {
reaction.throttleCasePopUps(device(DualApplePods.LidState.OPEN))
.shouldBeInstanceOf<PopUpReaction.Event.PopupShow>()
timeSource.advanceBy(Duration.ofSeconds(5))
reaction.throttleCasePopUps(device(DualApplePods.LidState.OPEN)) shouldBe null
}
private fun device(lid: DualApplePods.LidState?) = mockDevice(lid = lid)
}
@Nested
inner class StaleCloseTests {
private val timeSource = TestTimeSource(wallNow = Instant.parse("2026-01-01T00:00:00Z"))
private val reaction = PopUpReaction(
deviceMonitor = mockk(relaxed = true),
bluetoothManager = mockk(relaxed = true),
timeSource = timeSource,
)
@Test
fun `fresh OPEN broadcast keeps the popup`() {
reaction.isCaseOpenBroadcastFresh(mockDevice(DualApplePods.LidState.OPEN, lastSeen = timeSource.now())) shouldBe true
}
@Test
fun `OPEN broadcast older than the timeout does not keep the popup`() {
val stale = timeSource.now().minus(Duration.ofSeconds(10))
reaction.isCaseOpenBroadcastFresh(mockDevice(DualApplePods.LidState.OPEN, lastSeen = stale)) shouldBe false
}
@Test
fun `CLOSED lid is not kept open`() {
reaction.isCaseOpenBroadcastFresh(mockDevice(DualApplePods.LidState.CLOSED, lastSeen = timeSource.now())) shouldBe false
}
@Test
fun `null lid (dropped to cache, out of range) is not kept open`() {
reaction.isCaseOpenBroadcastFresh(mockDevice(lid = null, lastSeen = timeSource.now())) shouldBe false
}
@Test
fun `ineligible device is not kept open`() {
reaction.isCaseOpenBroadcastFresh(
mockDevice(DualApplePods.LidState.OPEN, eligible = false, lastSeen = timeSource.now())
) shouldBe false
}
@Test
fun `null device is not kept open`() {
reaction.isCaseOpenBroadcastFresh(null) shouldBe false
}
}
private fun mockDevice(
lid: DualApplePods.LidState?,
eligible: Boolean = true,
lastSeen: Instant = Instant.parse("2026-01-01T00:00:00Z"),
profile: String? = "profile-1",
): PodDevice = mockk(relaxed = true) {
every { caseLidState } returns lid
every { reactions } returns mockk(relaxed = true) {
every { showPopUpOnCaseOpen } returns eligible
}
// isCaseOpenBroadcastFresh reads BLE freshness via device.ble?.seenLastAt.
every { ble } returns mockk(relaxed = true) {
every { seenLastAt } returns lastSeen
}
every { profileId } returns profile
}
}
@@ -0,0 +1,46 @@
package eu.darken.capod.troubleshooter.ui
import eu.darken.capod.monitor.core.ble.BlePodMonitor
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.comparables.shouldBeLessThan
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class TroubleShooterViewModelTest : BaseTest() {
private fun BlePodMonitor.CompatOverride.disabledCount() =
listOf(offloadedFilteringDisabled, offloadedBatchingDisabled, indirectCallback).count { it }
@Test
fun `compat combos cover every combination exactly once`() {
val combos = TroubleShooterViewModel.COMPAT_COMBOS
combos shouldHaveSize 8
combos.toSet() shouldHaveSize 8
}
@Test
fun `compat combos start with the no-override baseline`() {
TroubleShooterViewModel.COMPAT_COMBOS.first() shouldBe
BlePodMonitor.CompatOverride(false, false, false)
}
@Test
fun `compat combos are ordered fewest-disables-first`() {
val counts = TroubleShooterViewModel.COMPAT_COMBOS.map { it.disabledCount() }
counts shouldBe counts.sorted()
}
@Test
fun `batching-only is probed before any combo that also disables filtering`() {
// The #603 fix: a phone that only needs batching disabled must land on the minimal combo,
// not on "all off", so we don't needlessly disable hardware filtering too.
val combos = TroubleShooterViewModel.COMPAT_COMBOS
val batchingOnly = combos.indexOf(BlePodMonitor.CompatOverride(false, true, false))
val filteringAndBatching = combos.indexOf(BlePodMonitor.CompatOverride(true, true, false))
val everything = combos.indexOf(BlePodMonitor.CompatOverride(true, true, true))
batchingOnly shouldBeLessThan filteringAndBatching
batchingOnly shouldBeLessThan everything
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
### Updated by tools/release/bump.sh ###
project.versioning.major=5
project.versioning.minor=1
project.versioning.patch=6
project.versioning.patch=7
project.versioning.build=0
project.versioning.type=rc
#############################