Compare commits

...
9 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_preamble">CAPod FOSS مجاني ومفتوح المصدر. إذا وجدته مفيدًا، ففكّر في دعم تطويره للمساعدة في استمرار المشروع.</string>
<string name="upgrade_foss_sponsor_action">دعم المشروع</string> <string name="upgrade_foss_sponsor_action">دعم المشروع</string>
<string name="upgrade_foss_sponsor_subtitle">لا إعلانات. لا تتبع. لا قيود من Google Play.</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> <string name="upgrade_badge_label">البرمجيات الحرة (FOSS)</string>
</resources> </resources>
@@ -83,6 +83,8 @@ class GeneralSettings @Inject constructor(
val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false) val reactionsHintDismissed = dataStore.createValue("ui.hint.reactions_per_device.dismissed", false)
val hideUnmatchedDevices = dataStore.createValue("ui.overview.unmatched.hidden", false)
val themeMode = dataStore.createValue( val themeMode = dataStore.createValue(
"core.ui.theme.mode", ThemeMode.SYSTEM, json, "core.ui.theme.mode", ThemeMode.SYSTEM, json,
onErrorFallbackToDefault = BuildConfigWrap.BUILD_TYPE != BuildConfigWrap.BuildType.DEV, 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.PermissionCard
import eu.darken.capod.main.ui.overview.cards.ReactionsMovedHintCard 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.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.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
@@ -172,6 +173,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
}, },
onManageDevices = { vm.goToDeviceManager() }, onManageDevices = { vm.goToDeviceManager() },
onSettings = { vm.goToSettings() }, onSettings = { vm.goToSettings() },
onTroubleShooter = { vm.goToTroubleShooter() },
onUpgrade = { vm.onUpgrade() }, onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() }, onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) }, onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
@@ -194,6 +196,7 @@ fun OverviewScreen(
onBluetoothSettings: () -> Unit, onBluetoothSettings: () -> Unit,
onManageDevices: () -> Unit, onManageDevices: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
onTroubleShooter: () -> Unit = {},
onUpgrade: () -> Unit, onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit, onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> 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 // 5. Monitoring active card
if (state.profiles.isNotEmpty() && state.devices.isEmpty()) { if (state.profiles.isNotEmpty() && state.profiledDevices.isEmpty() && !state.shouldShowUnmatchedSection) {
item(key = "monitoring_active") { item(key = "monitoring_active") {
MonitoringActiveCard() MonitoringActiveCard()
} }
} }
// 6. Unmatched devices section // 6. Unmatched devices section
if (state.unmatchedDevices.isNotEmpty()) { if (state.shouldShowUnmatchedSection) {
item(key = "unmatched_header") { item(key = "unmatched_header") {
UnmatchedDevicesCard( UnmatchedDevicesCard(
count = state.unmatchedDevices.size, count = state.visibleUnmatchedDevices.size,
isExpanded = state.showUnmatchedDevices, isExpanded = state.showUnmatchedDevices,
onToggle = onToggleUnmatched, onToggle = onToggleUnmatched,
) )
@@ -370,7 +380,7 @@ fun OverviewScreen(
if (state.showUnmatchedDevices) { if (state.showUnmatchedDevices) {
itemsIndexed( itemsIndexed(
items = state.unmatchedDevices, items = state.visibleUnmatchedDevices,
key = { index, device -> unmatchedDeviceKey(device, index) }, key = { index, device -> unmatchedDeviceKey(device, index) },
) { _, device -> ) { _, device ->
PodDeviceCard( PodDeviceCard(
@@ -34,9 +34,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine as combineFlows
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
@@ -78,6 +81,59 @@ class OverviewViewModel @Inject constructor(
private val showUnmatchedDevices = MutableStateFlow(false) private val showUnmatchedDevices = MutableStateFlow(false)
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet()) 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( val workerAutolaunch = combine(
permissionTool.missingScanPermissions, permissionTool.missingScanPermissions,
monitorModeResolver.effectiveMode, monitorModeResolver.effectiveMode,
@@ -134,9 +190,9 @@ class OverviewViewModel @Inject constructor(
upgradeRepo.upgradeInfo, upgradeRepo.upgradeInfo,
showUnmatchedDevices, showUnmatchedDevices,
userExpansionOverrides, userExpansionOverrides,
generalSettings.reactionsHintDismissed.flow, overviewUiSettings,
profilesRepo.hadLegacyReactionData, 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) // Prune stale overrides (profiles that no longer exist)
val currentProfileIds = profiles.map { it.id }.toSet() val currentProfileIds = profiles.map { it.id }.toSet()
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet() val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
@@ -151,7 +207,9 @@ class OverviewViewModel @Inject constructor(
upgradeInfo = upgradeInfo, upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched, showUnmatchedDevices = showUnmatched,
userExpandedIds = prunedExpandedIds, userExpandedIds = prunedExpandedIds,
showReactionsHint = hadLegacyReactionData && !reactionsHintDismissed, showReactionsHint = hadLegacyReactionData && !uiSettings.reactionsHintDismissed,
hideUnmatchedDevices = uiSettings.hideUnmatchedDevices,
showTroubleshootSuggestion = uiSettings.showTroubleshootSuggestion,
) )
}.asLiveState() }.asLiveState()
@@ -168,6 +226,8 @@ class OverviewViewModel @Inject constructor(
val showUnmatchedDevices: Boolean, val showUnmatchedDevices: Boolean,
val userExpandedIds: Set<String> = emptySet(), val userExpandedIds: Set<String> = emptySet(),
val showReactionsHint: Boolean = false, val showReactionsHint: Boolean = false,
val hideUnmatchedDevices: Boolean = false,
val showTroubleshootSuggestion: Boolean = false,
) { ) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking } 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 hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
val unmatchedDevices: List<PodDevice> get() = devices.filter { it.profileId == null } 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 val bluetoothIconState: BluetoothIconState
get() = when { get() = when {
isScanBlocked -> BluetoothIconState.HIDDEN isScanBlocked -> BluetoothIconState.HIDDEN
@@ -226,6 +291,11 @@ class OverviewViewModel @Inject constructor(
navTo(Nav.Settings.Index) navTo(Nav.Settings.Index)
} }
fun goToTroubleShooter() {
log(TAG, INFO) { "goToTroubleShooter()" }
navTo(Nav.Main.TroubleShooter)
}
fun goToDeviceManager() { fun goToDeviceManager() {
log(TAG, INFO) { "goToDeviceManager()" } log(TAG, INFO) { "goToDeviceManager()" }
navTo(Nav.Main.DeviceManager) navTo(Nav.Main.DeviceManager)
@@ -286,6 +356,14 @@ class OverviewViewModel @Inject constructor(
companion object { companion object {
private const val FREE_DEVICE_LIMIT = 1 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") 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.automirrored.twotone.Message
import androidx.compose.material.icons.twotone.Notifications import androidx.compose.material.icons.twotone.Notifications
import androidx.compose.material.icons.twotone.Palette 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.material.icons.automirrored.twotone.ViewList
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -58,6 +59,7 @@ fun GeneralSettingsScreenHost(vm: GeneralSettingsViewModel = hiltViewModel()) {
onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) }, onOffloadedFilteringDisabledChanged = { disabled -> vm.setOffloadedFilteringDisabled(disabled) },
onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) }, onOffloadedBatchingDisabledChanged = { disabled -> vm.setOffloadedBatchingDisabled(disabled) },
onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) }, onUseIndirectScanResultCallbackChanged = { enabled -> vm.setUseIndirectScanResultCallback(enabled) },
onHideUnmatchedDevicesChanged = { enabled -> vm.setHideUnmatchedDevices(enabled) },
onThemeModeSelected = { mode -> vm.setThemeMode(mode) }, onThemeModeSelected = { mode -> vm.setThemeMode(mode) },
onThemeStyleSelected = { style -> vm.setThemeStyle(style) }, onThemeStyleSelected = { style -> vm.setThemeStyle(style) },
onThemeColorSelected = { color -> vm.setThemeColor(color) }, onThemeColorSelected = { color -> vm.setThemeColor(color) },
@@ -75,6 +77,7 @@ fun GeneralSettingsScreen(
onOffloadedFilteringDisabledChanged: (Boolean) -> Unit, onOffloadedFilteringDisabledChanged: (Boolean) -> Unit,
onOffloadedBatchingDisabledChanged: (Boolean) -> Unit, onOffloadedBatchingDisabledChanged: (Boolean) -> Unit,
onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit, onUseIndirectScanResultCallbackChanged: (Boolean) -> Unit,
onHideUnmatchedDevicesChanged: (Boolean) -> Unit,
onThemeModeSelected: (ThemeMode) -> Unit = {}, onThemeModeSelected: (ThemeMode) -> Unit = {},
onThemeStyleSelected: (ThemeStyle) -> Unit = {}, onThemeStyleSelected: (ThemeStyle) -> Unit = {},
onThemeColorSelected: (ThemeColor) -> 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 { item {
SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title)) SettingsCategoryHeader(text = stringResource(R.string.settings_category_compatibility_options_title))
} }
@@ -267,6 +285,7 @@ private fun previewGeneralState(isPro: Boolean) = GeneralSettingsViewModel.State
isOffloadedFilteringDisabled = false, isOffloadedFilteringDisabled = false,
isOffloadedBatchingDisabled = false, isOffloadedBatchingDisabled = false,
useIndirectScanResultCallback = false, useIndirectScanResultCallback = false,
hideUnmatchedDevices = false,
themeState = ThemeState(), themeState = ThemeState(),
) )
@@ -281,6 +300,7 @@ private fun GeneralSettingsScreenProPreview() = PreviewWrapper {
onOffloadedFilteringDisabledChanged = {}, onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {}, onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {}, onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
) )
} }
@@ -295,5 +315,6 @@ private fun GeneralSettingsScreenNonProPreview() = PreviewWrapper {
onOffloadedFilteringDisabledChanged = {}, onOffloadedFilteringDisabledChanged = {},
onOffloadedBatchingDisabledChanged = {}, onOffloadedBatchingDisabledChanged = {},
onUseIndirectScanResultCallbackChanged = {}, onUseIndirectScanResultCallbackChanged = {},
onHideUnmatchedDevicesChanged = {},
) )
} }
@@ -34,6 +34,7 @@ class GeneralSettingsViewModel @Inject constructor(
val isOffloadedFilteringDisabled: Boolean, val isOffloadedFilteringDisabled: Boolean,
val isOffloadedBatchingDisabled: Boolean, val isOffloadedBatchingDisabled: Boolean,
val useIndirectScanResultCallback: Boolean, val useIndirectScanResultCallback: Boolean,
val hideUnmatchedDevices: Boolean,
val themeState: ThemeState, val themeState: ThemeState,
) )
@@ -57,7 +58,8 @@ class GeneralSettingsViewModel @Inject constructor(
}, },
generalSettings.themeState, generalSettings.themeState,
isPro, isPro,
) { general, compat, themeState, isPro -> generalSettings.hideUnmatchedDevices.flow,
) { general, compat, themeState, isPro, hideUnmatched ->
State( State(
isPro = isPro, isPro = isPro,
showConnectedNotification = general[0] as Boolean, showConnectedNotification = general[0] as Boolean,
@@ -65,6 +67,7 @@ class GeneralSettingsViewModel @Inject constructor(
isOffloadedFilteringDisabled = compat[0] as Boolean, isOffloadedFilteringDisabled = compat[0] as Boolean,
isOffloadedBatchingDisabled = compat[1] as Boolean, isOffloadedBatchingDisabled = compat[1] as Boolean,
useIndirectScanResultCallback = compat[2] as Boolean, useIndirectScanResultCallback = compat[2] as Boolean,
hideUnmatchedDevices = hideUnmatched,
themeState = themeState, themeState = themeState,
) )
}.asLiveState() }.asLiveState()
@@ -94,6 +97,11 @@ class GeneralSettingsViewModel @Inject constructor(
generalSettings.useIndirectScanResultCallback.valueBlocking = enabled generalSettings.useIndirectScanResultCallback.valueBlocking = enabled
} }
fun setHideUnmatchedDevices(enabled: Boolean) {
log(TAG, INFO) { "setHideUnmatchedDevices($enabled)" }
generalSettings.hideUnmatchedDevices.valueBlocking = enabled
}
fun setThemeMode(mode: ThemeMode) = launch { fun setThemeMode(mode: ThemeMode) = launch {
log(TAG, INFO) { "setThemeMode($mode)" } log(TAG, INFO) { "setThemeMode($mode)" }
if (isPro.first()) { if (isPro.first()) {
@@ -59,6 +59,17 @@ class BlePodMonitor @Inject constructor(
private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>() private val deviceCache = mutableMapOf<BlePodSnapshot.Id, BlePodSnapshot>()
private val cacheLock = Mutex() 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 * Ephemeral override that disables the proximity-pairing scan filter so
* the troubleshooter can collect raw BLE broadcasts. Resets to false on * the troubleshooter can collect raw BLE broadcasts. Resets to false on
@@ -70,6 +81,40 @@ class BlePodMonitor @Inject constructor(
unfilteredOverride.value = enabled 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( val devices: Flow<List<BlePodSnapshot>> = combine(
permissionTool.missingScanPermissions, permissionTool.missingScanPermissions,
bluetoothManager.isBluetoothEnabled bluetoothManager.isBluetoothEnabled
@@ -145,22 +190,14 @@ class BlePodMonitor @Inject constructor(
private fun createBleScanner() = combine( private fun createBleScanner() = combine(
bleScanModeController.scannerMode, bleScanModeController.scannerMode,
unfilteredOverride, unfilteredOverride,
generalSettings.isOffloadedBatchingDisabled.flow, effectiveCompat,
generalSettings.isOffloadedFilteringDisabled.flow, ) { scannermode, showUnfiltered, compat ->
generalSettings.useIndirectScanResultCallback.flow,
) {
scannermode,
showUnfiltered,
isOffloadedBatchingDisabled,
isOffloadedFilteringDisabled,
useIndirectScanResultCallback,
->
ScannerOptions( ScannerOptions(
scannerMode = scannermode, scannerMode = scannermode,
showUnfiltered = showUnfiltered, showUnfiltered = showUnfiltered,
offloadedFilteringDisabled = isOffloadedFilteringDisabled, offloadedFilteringDisabled = compat.offloadedFilteringDisabled,
offloadedBatchingDisabled = isOffloadedBatchingDisabled, offloadedBatchingDisabled = compat.offloadedBatchingDisabled,
disableDirectCallback = useIndirectScanResultCallback, disableDirectCallback = compat.indirectCallback,
) )
} }
.throttleLatest(1000) .throttleLatest(1000)
@@ -102,10 +102,10 @@ sealed class AapSetting {
/** /**
* Push-only from device — reports speaking detection state (command 0x4B). * Push-only from device — reports speaking detection state (command 0x4B).
* *
* [rawValue] is the first payload byte, preserved so consumers can distinguish the known * [rawValue] is the status byte: the last byte of the 4-byte `02 00 01 XX` form (or the single
* speaking-start (0x01) and speaking-stop (0x04) markers from other values (e.g. 0x00, seen * byte of the legacy form), preserved so consumers can classify it. [speaking] is `true` only
* in captures with unclear meaning). [speaking] collapses everything non-0x01 to false for * for the speaking-onset statuses (`1`, `2`); every other value (`0`, `3`, `4`, `5`, `0x0B`, …)
* storage/UI; reaction logic must gate on [rawValue] to avoid acting on unknown values. * is `false`. START/STOP/HOLD classification for the reaction lives in [ConversationAwarenessEvent].
*/ */
data class ConversationalAwarenessState( data class ConversationalAwarenessState(
val speaking: Boolean, 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. * 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) * - `1`, `2` → [START] (wearer started / is speaking → engage the reaction)
* - `6`, `8`, `9` → [STOP] (wearer stopped → disengage) * - `5`, `6`, `8`, `9` → [STOP] (wearer stopped → disengage). `5` is the terminal value on fw `…6861`,
* - any other value (`3`, `4`, `0x0B`, …) → [HOLD] (intermediate "still in session" frame; the pod * which winds down `3`→`5` and never reaches `6/8/9`; `6/8/9` are the terminal values on fw `…6503`.
* streams these while speaking — they act as a keep-alive and must NOT disengage the reaction) * - 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 * Frame cadence is firmware-dependent and the pod does NOT reliably stream keep-alives while you
* reliable "speaking ended" signal (used as a stale-timeout fallback for a missed [STOP]). * 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 { enum class ConversationAwarenessEvent {
START, START,
@@ -20,7 +25,7 @@ enum class ConversationAwarenessEvent {
companion object { companion object {
val SPEAKING_STATUSES = setOf(1, 2) 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) { fun fromStatus(status: Int): ConversationAwarenessEvent = when (status) {
in SPEAKING_STATUSES -> START 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.history.KnownDevice
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage import eu.darken.capod.pods.core.apple.ble.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload 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 { interface ApplePodsFactory {
fun isResponsible(message: ProximityMessage): Boolean fun isResponsible(message: ProximityMessage): Boolean
@@ -22,7 +30,8 @@ interface ApplePodsFactory {
fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? { 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( if (basic.hasCaseContext && basic.caseLidState in setOf(
DualApplePods.LidState.OPEN, DualApplePods.LidState.OPEN,
DualApplePods.LidState.CLOSED, DualApplePods.LidState.CLOSED,
@@ -31,11 +40,14 @@ interface ApplePodsFactory {
return basic.caseLidState return basic.caseLidState
} }
// Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN. // Current pod lacks case context (e.g. pod on desk) or reports UNKNOWN (out-of-case pod's
// Check recent history for a sibling broadcast that has case context. // stale lid byte). Recover the last authoritative reading from a recent in-case broadcast,
val fromCaseContext = history // bounded by time so a missed CLOSED can't keep a stale OPEN alive across scan gaps.
.takeLast(4) val recentHistory = history
.filterIsInstance<DualApplePods>() .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 } .lastOrNull { it.hasCaseContext && it.caseLidState != DualApplePods.LidState.UNKNOWN }
?.caseLidState ?.caseLidState
@@ -44,10 +56,8 @@ interface ApplePodsFactory {
// No case-context broadcast in recent history — current value is best we have // No case-context broadcast in recent history — current value is best we have
if (basic.caseLidState != DualApplePods.LidState.UNKNOWN) return basic.caseLidState if (basic.caseLidState != DualApplePods.LidState.UNKNOWN) return basic.caseLidState
// Last resort: any non-UNKNOWN from history // Last resort: any non-UNKNOWN from recent history
return history return recentHistory
.takeLast(2)
.filterIsInstance<DualApplePods>()
.lastOrNull { it.caseLidState != DualApplePods.LidState.UNKNOWN } .lastOrNull { it.caseLidState != DualApplePods.LidState.UNKNOWN }
?.caseLidState ?.caseLidState
?: DualApplePods.LidState.NOT_IN_CASE ?: DualApplePods.LidState.NOT_IN_CASE
@@ -148,7 +148,14 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
get() = isThisPodInThecase || isOnePodInCase || areBothPodsInCase get() = isThisPodInThecase || isOnePodInCase || areBothPodsInCase
val caseLidState: LidState 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 * TODO this is glitchy
@@ -165,8 +172,20 @@ interface DualApplePods : ApplePods, HasChargeDetectionDual, DualBlePodSnapshot,
UNKNOWN; UNKNOWN;
companion object { 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 (!hasCaseContext) return NOT_IN_CASE
if (!lidReadingReliable) return UNKNOWN
return when ((raw.toInt() shr 3) and 0x01) { return when ((raw.toInt() shr 3) and 0x01) {
0 -> OPEN 0 -> OPEN
@@ -39,7 +39,9 @@ class AutoConnect @Inject constructor(
combine( combine(
bluetoothManager.connectedDevices, bluetoothManager.connectedDevices,
deviceMonitor.primaryDevice().filterNotNull().distinctUntilChangedBy { 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, mainDevice ->
connectedDevices to mainDevice connectedDevices to mainDevice
@@ -30,17 +30,22 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton 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 * 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 * 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. * 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, * Disengage is driven by the pod's explicit end-of-speech frame ([ConversationAwarenessEvent.STOP]).
* then [ConversationAwarenessEvent.HOLD] keep-alives) and an explicit [ConversationAwarenessEvent.STOP] * The pod does NOT reliably stream keep-alive frames while you talk — on some firmware it sends an
* when you finish; no frames at all during silence. Disengage happens on STOP, or — if a STOP is * onset ([ConversationAwarenessEvent.START]) then nothing for many seconds while speech continues
* dropped — via a stale timeout that fires once frames stop arriving. Each frame (START or HOLD) * (observed: CA held engaged 21s with zero `0x4B` frames). So frame-silence must NOT be read as
* resets that timer, so a long conversation stays engaged. * "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 * 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. * 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. // surprising behaviour. The remaining guards are about real device/playback state.
val age = timeSource.elapsedRealtime() - record.at val age = timeSource.elapsedRealtime() - record.at
when { when {
age > PAUSE_RESUME_WINDOW_MS -> age.milliseconds > PAUSE_RESUME_WINDOW ->
log(TAG) { "$reason — resume skipped (stale, ${age}ms)" } log(TAG) { "$reason — resume skipped (stale, ${age}ms)" }
primary?.address != record.owner -> primary?.address != record.owner ->
log(TAG) { "$reason — resume skipped (primary switched)" } 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 * 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_MS] the speaking session is treated as * (START/HOLD). If no frame arrives for [STALE_TIMEOUT] the session is force-ended — a last
* ended (recovers from a dropped STOP). Identity-checked so a late timer can't disengage a newer * resort for a fully-dropped terminal frame, not the normal disengage. Identity-checked so a
* session. * late timer can't disengage a newer session.
*/ */
private fun restartStaleTimer(record: Active) { private fun restartStaleTimer(record: Active) {
staleJob?.cancel() staleJob?.cancel()
staleJob = appScope.launch { staleJob = appScope.launch {
delay(STALE_TIMEOUT_MS) delay(STALE_TIMEOUT)
val primary = deviceMonitor.primaryDevice().first() val primary = deviceMonitor.primaryDevice().first()
mutex.withLock { mutex.withLock {
if (active?.id == record.id) { if (active?.id == record.id) {
@@ -268,14 +273,17 @@ class ConversationReaction @Inject constructor(
private val TAG = logTag("Reaction", "Conversation") private val TAG = logTag("Reaction", "Conversation")
/** /**
* Disengage if no `0x4B` frame arrives for this long while engaged. The pod streams frames * Pure backstop: disengage if no `0x4B` frame arrives for this long while engaged. This is
* (~1/s) throughout active speech and none during silence, so this both recovers a dropped * NOT the normal disengage path — the pod does not stream keep-alives during speech, so a
* STOP and bounds how long a duck can linger. Long enough not to disengage mid-conversation * short timeout would fire mid-conversation (the original 12s value did exactly that).
* between frames. * 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. */ /** A pause older than this no longer auto-resumes — unexpected late playback is worse. */
private const val PAUSE_RESUME_WINDOW_MS = 2L * 60L * 1000L 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.PodDevice
import eu.darken.capod.monitor.core.primaryDevice import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import java.time.Duration import java.time.Duration
@@ -34,8 +37,12 @@ class PopUpReaction @Inject constructor(
private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDevice() private fun monitorCase(): Flow<Event> = deviceMonitor.primaryDevice()
.distinctUntilChangedBy { .distinctUntilChangedBy {
// Re-emit on profile changes (eligibility) AND on raw BLE state changes (lid). // Re-emit on profile changes (eligibility), raw BLE changes (content), AND the derived
Triple(it?.profileId, it?.reactions?.showPopUpOnCaseOpen, it?.rawDataHex) // 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() .withPrevious()
.setupCommonEventHandlers(TAG) { "popUpCase" } .setupCommonEventHandlers(TAG) { "popUpCase" }
@@ -71,7 +78,7 @@ class PopUpReaction @Inject constructor(
throttleCasePopUps(current) throttleCasePopUps(current)
} }
private fun throttleCasePopUps(current: PodDevice): Event? { internal fun throttleCasePopUps(current: PodDevice): Event? {
val cooldownKey = current.profileId ?: current.identifier?.toString() ?: return null val cooldownKey = current.profileId ?: current.identifier?.toString() ?: return null
val now = timeSource.now() val now = timeSource.now()
val lastShown = caseCoolDowns[cooldownKey] val lastShown = caseCoolDowns[cooldownKey]
@@ -95,9 +102,9 @@ class PopUpReaction @Inject constructor(
} }
decision.shouldHide -> { decision.shouldHide -> {
if (!decision.shouldResetCooldown) { // Don't stamp the cooldown on a non-CLOSED hide (UNKNOWN/NOT_IN_CASE). Refreshing it
caseCoolDowns[cooldownKey] = now // 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) Event.PopupHide(now)
} }
@@ -192,7 +199,48 @@ class PopUpReaction @Inject constructor(
} }
.setupCommonEventHandlers(TAG) { "popUpConnection" } .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 { sealed class Event {
data class PopupShow( data class PopupShow(
@@ -296,5 +344,11 @@ class PopUpReaction @Inject constructor(
companion object { companion object {
private val TAG = logTag("Reaction", "PopUp") 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.coroutine.DispatcherProvider
import eu.darken.capod.common.datastore.valueBlocking 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.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.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.uix.ViewModel4 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.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject import javax.inject.Inject
@@ -47,6 +47,9 @@ class TroubleShooterViewModel @Inject constructor(
private val _bleState = MutableStateFlow<BleState>(BleState.Intro()) 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) data class State(val bleState: BleState)
val state = _bleState.map { State(it) }.asLiveState() val state = _bleState.map { State(it) }.asLiveState()
@@ -82,169 +85,184 @@ class TroubleShooterViewModel @Inject constructor(
} }
fun troubleShootBle() = launch(context = dispatcherProvider.IO) { 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()" } log(TAG, INFO) { "troubleShootBle()" }
bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{ try {
try { bleScanModeController.withTemporaryOverride(ScannerMode.LOW_LATENCY) override@{
run { // The combo under which supported headphones were detected (set in sweep 2).
progress("Checking for headphones...") var supportedCombo: BlePodMonitor.CompatOverride? = null
val mainDevice = withTimeoutOrNull(STEP_TIME) { // Set only when the run reaches a terminal success. Persisted on exit; staying null
deviceMonitor.primaryDevice().filterNotNull().firstOrNull() // (any failure, or cancellation) means we just clear the override, which restores the
} // user's original — never-touched — settings.
if (mainDevice != null) { var comboToPersist: BlePodMonitor.CompatOverride? = null
success("Headphones found, nothing to troubleshoot.") try {
return@override run {
} else { progress("Checking for headphones...")
progress("Headphones not detected.\n") 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> = val doScan: suspend (BlePodMonitor.CompatOverride, Boolean) -> Collection<BlePodSnapshot> =
{ hardwareFilteringDisabled, { combo, unfiltered ->
hardwareBatchingDisabled, progress(
indirectCallback, "SCAN - Settings: filteringDisabled=${combo.offloadedFilteringDisabled}, " +
unfiltered -> "batchingDisabled=${combo.offloadedBatchingDisabled}, " +
val sb = StringBuilder("SCAN - Settings: ") "indirectCallback=${combo.indirectCallback}, unfiltered=$unfiltered"
sb.append("hardwareFilteringDisabled=$hardwareFilteringDisabled, ") )
sb.append("hardwareBatchingDisabled=$hardwareBatchingDisabled, ") blePodMonitor.setCompatOverride(combo)
sb.append("indirectCallback=$indirectCallback, ") blePodMonitor.setUnfilteredOverride(unfiltered)
sb.append("unfiltered=$unfiltered") val devices = collectFreshDevices()
progress(sb.toString()) log(TAG) { "SCAN: Fresh BLE devices: $devices" }
generalSettings.isOffloadedFilteringDisabled.valueBlocking = hardwareFilteringDisabled if (devices.isNotEmpty()) {
generalSettings.isOffloadedBatchingDisabled.valueBlocking = hardwareBatchingDisabled progress("SCAN: Received data from ${devices.size} BLE devices")
generalSettings.useIndirectScanResultCallback.valueBlocking = indirectCallback } else {
blePodMonitor.setUnfilteredOverride(unfiltered) progress("SCAN: No data received")
}
devices
}
val start = timeSource.elapsedRealtime() run {
val devices = withTimeoutOrNull(STEP_TIME) { progress("Checking if we can receive BLE data at all.")
blePodMonitor.devices val gotData = COMPAT_COMBOS.any { combo -> doScan(combo, true).isNotEmpty() }
.take(10) if (!gotData) {
.takeWhile { timeSource.elapsedRealtime() - start < STEP_TIME - 1000 } failure("Phone is not receiving BLE data.", BleState.Result.Failure.Type.PHONE)
.toList() return@override
.flatten() }
.distinctBy { it.address } }
} ?: emptyList()
log(TAG) { "SCAN: BLE Devices: $devices" } progress("We received at least some BLE data.\n")
if (devices.isNotEmpty()) {
progress("SCAN: Received data from ${devices.size} BLE devices") run {
devices progress("Checking for supported headphones.")
} else { supportedCombo = COMPAT_COMBOS.firstOrNull { combo ->
progress("SCAN: No data received") doScan(combo, false).any { it !is UnknownSnapshotBle }
devices }
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 { sealed class BleState {
class Intro : BleState() class Intro : BleState()
@@ -295,6 +313,29 @@ class TroubleShooterViewModel @Inject constructor(
companion object { companion object {
const val STEP_TIME = 10 * 1000L 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") 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_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_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_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_connected_device_media">Speel media</string>
<string name="device_settings_noise_control_label">Geraasbeheer</string> <string name="device_settings_noise_control_label">Geraasbeheer</string>
<string name="device_settings_eq_label">Egaliseerder</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_settings">إعدادات الجهاز المتقدّمة</string>
<string name="upgrade_benefit_device_controls">عناصر التحكم في الجهاز &#8212; الضوضاء، &amp; السيقان، &amp; المزيد</string> <string name="upgrade_benefit_device_controls">عناصر التحكم في الجهاز &#8212; الضوضاء، &amp; السيقان، &amp; المزيد</string>
<string name="upgrade_benefit_support">دعم المطوّر</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_options_description">نفس الميزات، أسعار مختلفة. يشمل الاشتراك فترة تجريبية مجانية ويمكن إلغاؤه في أي وقت.</string>
<string name="upgrade_screen_subscription_trial_action">بدء التجربة المجانية</string> <string name="upgrade_screen_subscription_trial_action">بدء التجربة المجانية</string>
<string name="upgrade_screen_subscription_action">الاشتراك سنويًا</string> <string name="upgrade_screen_subscription_action">الاشتراك سنويًا</string>
<string name="upgrade_screen_subscription_action_hint">%s/سنة</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_iap_action_hint">الشراء لمرة واحدة: %s</string>
<string name="upgrade_screen_restore_purchase_action">استعادة المشتريات</string> <string name="upgrade_screen_restore_purchase_action">استعادة المشتريات</string>
<string name="upgrade_screen_restore_purchase_message">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string> <string name="upgrade_screen_restore_purchase_message">لم يتم العثور على أي مشتريات. هل تستخدم الحساب الصحيح؟</string>
@@ -44,18 +44,18 @@
<string name="settings_monitor_connected_notification_description">يعرض إشعارًا إضافيًا عند توصيل جهاز. يتيح لك هذا إخفاء الإشعار الدائم \"لا توجد أجهزة\" عن طريق تعطيل قناة \"حالة الجهاز\".</string> <string name="settings_monitor_connected_notification_description">يعرض إشعارًا إضافيًا عند توصيل جهاز. يتيح لك هذا إخفاء الإشعار الدائم \"لا توجد أجهزة\" عن طريق تعطيل قناة \"حالة الجهاز\".</string>
<string name="settings_keep_notification_after_disconnect_label">الاحتفاظ بالإشعار بعد قطع الاتصال</string> <string name="settings_keep_notification_after_disconnect_label">الاحتفاظ بالإشعار بعد قطع الاتصال</string>
<string name="settings_keep_notification_after_disconnect_description">الاستمرار في عرض مستويات البطارية المعروفة الأخيرة حتى بعد قطع اتصال AirPods</string> <string name="settings_keep_notification_after_disconnect_description">الاستمرار في عرض مستويات البطارية المعروفة الأخيرة حتى بعد قطع اتصال AirPods</string>
<string name="settings_autopause_label">إيقاف مؤقت تلقائي</string> <string name="settings_autopause_label">إيقاف مؤقّت تلقائي</string>
<string name="settings_autopause_description">إيقاف الصوت مؤقتًا عند إزالة الجهاز من أذنك.</string> <string name="settings_autopause_description">إيقاف الصوت مؤقّتًا عند إزالة الجهاز من أذنك.</string>
<string name="settings_autopplay_label">تشغيل تلقائي</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_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_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_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_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_info_android12">يعتمد الاتصال التلقائي على ميزة نظام يقيّدها Android 12 والإصدارات الأحدث. قد لا تعمل على جهازك.</string>
<string name="settings_autoconnect_condition_label">شرط الاتصال التلقائي</string> <string name="settings_autoconnect_condition_label">شرط الاتصال التلقائي</string>
<string name="settings_autoconnect_condition_description">متى يجب أن نحاول الاتصال بجهازك؟</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_config_aap_required_hint">يتطلب هذا الودجت اتصالاً مباشراً (AAP) بجهازك. تأكد من إقران جهازك وتوصيله في التطبيق قبل استخدام هذا الودجت.</string>
<string name="anc_widget_no_anc_support_label">هذا الجهاز لا يدعم التحكم في الضوضاء</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_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_not_connected_not_nearby_description">غير قريب</string>
<string name="anc_widget_aap_connecting_label">جارٍ الاتصال…</string> <string name="anc_widget_aap_connecting_label">جارٍ الاتصال…</string>
<string name="tile_anc_label">التحكم في الضوضاء</string> <string name="tile_anc_label">التحكم في الضوضاء</string>
<string name="tile_anc_subtitle_permission_required">الإذن مطلوب</string> <string name="tile_anc_subtitle_permission_required">الإذن مطلوب</string>
<string name="tile_anc_subtitle_no_device">لا يوجد جهاز</string> <string name="tile_anc_subtitle_no_device">لا يوجد جهاز</string>
<string name="tile_anc_subtitle_no_anc_support">لا يوجد تحكم في الضوضاء</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_config_screen_title">إعداد عنصر الواجهة</string>
<string name="widget_configuration_title">اختيار الجهاز</string> <string name="widget_configuration_title">اختيار الجهاز</string>
<string name="widget_configuration_description">اختر ملف تعريف الجهاز الذي يجب أن تعرضه هذه الأداة.</string> <string name="widget_configuration_description">اختر ملف تعريف الجهاز الذي يجب أن تعرضه هذه الأداة.</string>
@@ -488,7 +488,7 @@
<!-- New device settings --> <!-- New device settings -->
<string name="device_settings_category_general_label">عام</string> <string name="device_settings_category_general_label">عام</string>
<string name="device_settings_microphone_mode_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_auto">تلقائي</string>
<string name="device_settings_microphone_mode_right">يمين</string> <string name="device_settings_microphone_mode_right">يمين</string>
<string name="device_settings_microphone_mode_left">يسار</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_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_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_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_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-se els auriculars</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 et poses els auriculars, fins i tot després d\'una pausa manual.</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_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_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> <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_transparency">Transparència</string>
<string name="anc_mode_adaptive">Adaptatiu</string> <string name="anc_mode_adaptive">Adaptatiu</string>
<string name="conversation_awareness_label">Consciència de conversa</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_label">Quan comenceu a parlar</string>
<string name="settings_conversation_action_nothing_label">No facis res</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_lower_volume_label">Baixa el volum</string>
<string name="settings_conversation_action_pause_label">Posa en pausa el reproductor</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> <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_label">Automaatne peatamine</string>
<string name="settings_autopause_description">Seadme kõrvast eemaldamisel peata esitamine.</string> <string name="settings_autopause_description">Seadme kõrvast eemaldamisel peata esitamine.</string>
<string name="settings_autopplay_label">Automaatne 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_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">Käivita muusika kandmisel</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 mängida muusikat, kui paned kõrvaklapid pähe, isegi pärast käsitsi pausi.</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_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_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> <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_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_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_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_not_connected_not_nearby_description">Pole lähedal</string>
<string name="anc_widget_aap_connecting_label">Ühendamine…</string> <string name="anc_widget_aap_connecting_label">Ühendamine…</string>
<string name="tile_anc_label">Mürakontroll</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_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="tile_anc_subtitle_bluetooth_off">Bluetooth väljas</string>
<string name="widget_config_screen_title">Vidina seadistus</string> <string name="widget_config_screen_title">Vidina seadistus</string>
<string name="widget_configuration_title">Vali seade</string> <string name="widget_configuration_title">Vali seade</string>
@@ -250,7 +250,7 @@
<string name="conversation_awareness_label">Vestluse tuvastamine</string> <string name="conversation_awareness_label">Vestluse tuvastamine</string>
<string name="settings_conversation_action_label">Kui alustad rääkimist</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_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_action_pause_label">Peata meedia</string>
<string name="settings_conversation_volume_reduction_label">Helitugevuse vähendamine</string> <string name="settings_conversation_volume_reduction_label">Helitugevuse vähendamine</string>
<string name="signal_badge_ble_cd">BLE skannimine aktiivne</string> <string name="signal_badge_ble_cd">BLE skannimine aktiivne</string>
@@ -468,7 +468,7 @@
<!-- New device settings --> <!-- New device settings -->
<string name="device_settings_category_general_label">Üldine</string> <string name="device_settings_category_general_label">Üldine</string>
<string name="device_settings_microphone_mode_label">Mikrofon</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_auto">Automaatne</string>
<string name="device_settings_microphone_mode_right">Parem</string> <string name="device_settings_microphone_mode_right">Parem</string>
<string name="device_settings_microphone_mode_left">Vasak</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_label">Автопауза</string>
<string name="settings_autopause_description">Приостановка аудио при удалении устройства из уха.</string> <string name="settings_autopause_description">Приостановка аудио при удалении устройства из уха.</string>
<string name="settings_autopplay_label">Автовоспроизведение</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_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_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_label">Минимальное качество сигнала</string>
@@ -253,10 +253,10 @@ AL_Cool_T</string>
<string name="anc_mode_transparency">Прозрачность</string> <string name="anc_mode_transparency">Прозрачность</string>
<string name="anc_mode_adaptive">Адаптивный</string> <string name="anc_mode_adaptive">Адаптивный</string>
<string name="conversation_awareness_label">Распознавание голоса в разговоре</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_nothing_label">Ничего не делать</string>
<string name="settings_conversation_action_lower_volume_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="settings_conversation_volume_reduction_label">Снижение громкости</string>
<string name="signal_badge_ble_cd">BLE сканирование активно</string> <string name="signal_badge_ble_cd">BLE сканирование активно</string>
<string name="signal_badge_key_irk_cd">Личность подтверждена</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_category_debug_label">Debug</string>
<string name="settings_general_label">Settings</string> <string name="settings_general_label">Settings</string>
<string name="settings_general_description">General tweaks that affect the whole app.</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_acknowledgements_label">Acknowledgements</string>
<string name="settings_debug_autoreports_label">Automatic bug reports</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_bluetooth_disabled_description">Bluetooth is disabled, enable it ;)</string>
<string name="overview_monitoring_active_label">Monitoring for devices</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_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> <string name="overview_unmatched_devices_label">Unmatched devices</string>
<plurals name="overview_unmatched_devices_count"> <plurals name="overview_unmatched_devices_count">
<item quantity="one">%d device without matching profile</item> <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.MonitorModeResolver
import eu.darken.capod.monitor.core.PodDevice import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.worker.MonitorControl 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.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
@@ -25,6 +27,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -64,6 +67,7 @@ class OverviewViewModelTest : BaseTest() {
private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info> private lateinit var upgradeInfoFlow: MutableStateFlow<UpgradeRepo.Info>
private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode> private lateinit var effectiveModeFlow: MutableStateFlow<MonitorMode>
private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean> private lateinit var fakeReactionsHintDismissed: FakeDataStoreValue<Boolean>
private lateinit var fakeHideUnmatchedDevices: FakeDataStoreValue<Boolean>
@BeforeEach @BeforeEach
fun setup() { fun setup() {
@@ -78,6 +82,7 @@ class OverviewViewModelTest : BaseTest() {
upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(relaxed = true)) upgradeInfoFlow = MutableStateFlow(mockk<UpgradeRepo.Info>(relaxed = true))
effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC) effectiveModeFlow = MutableStateFlow(MonitorMode.AUTOMATIC)
fakeReactionsHintDismissed = FakeDataStoreValue(false) fakeReactionsHintDismissed = FakeDataStoreValue(false)
fakeHideUnmatchedDevices = FakeDataStoreValue(false)
Bugs.isDebug.value = false Bugs.isDebug.value = false
monitorControl = mockk(relaxed = true) monitorControl = mockk(relaxed = true)
@@ -95,6 +100,7 @@ class OverviewViewModelTest : BaseTest() {
generalSettings = mockk<GeneralSettings>().also { generalSettings = mockk<GeneralSettings>().also {
every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock every { it.reactionsHintDismissed } returns fakeReactionsHintDismissed.mock
every { it.hideUnmatchedDevices } returns fakeHideUnmatchedDevices.mock
} }
monitorModeResolver = mockk<MonitorModeResolver>().also { monitorModeResolver = mockk<MonitorModeResolver>().also {
@@ -341,6 +347,68 @@ class OverviewViewModelTest : BaseTest() {
state.visibleProfiledDevices shouldBe listOf(profiled1) state.visibleProfiledDevices shouldBe listOf(profiled1)
state.hiddenProfiledDeviceCount shouldBe 1 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 @Nested
@@ -489,4 +557,86 @@ class OverviewViewModelTest : BaseTest() {
event shouldBe Permission.BLUETOOTH 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 @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(6) shouldBe ConversationAwarenessEvent.STOP
firstEventFor(8) shouldBe ConversationAwarenessEvent.STOP firstEventFor(8) shouldBe ConversationAwarenessEvent.STOP
firstEventFor(9) shouldBe ConversationAwarenessEvent.STOP firstEventFor(9) shouldBe ConversationAwarenessEvent.STOP
} }
@Test @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(3) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(4) shouldBe ConversationAwarenessEvent.HOLD
firstEventFor(0x0B) 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.history.KnownDevice
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Instant import java.time.Instant
@@ -250,13 +251,34 @@ class DualApplePodsTest : BaseBlePodsTest() {
@Test @Test
fun `test AirPodDevice - case lid uses status derived case context`() { 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 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 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 = 0x2B, rawCaseLidState = 0x11).caseLidState shouldBe DualApplePods.LidState.NOT_IN_CASE
directAirPodsPro(status = 0x20, rawCaseLidState = 0x5A).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 { private fun knownDeviceOf(vararg pods: AirPodsPro): KnownDevice {
val id = pods.first().identifier val id = pods.first().identifier
return KnownDevice( return KnownDevice(
@@ -315,6 +337,47 @@ class DualApplePodsTest : BaseBlePodsTest() {
result shouldBe DualApplePods.LidState.NOT_IN_CASE 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 @Test
fun `test AirPodDevice - connection state`() = runTest { fun `test AirPodDevice - connection state`() = runTest {
// Disconnected // Disconnected
@@ -31,8 +31,9 @@ class ConversationReactionTest : BaseTest() {
private val primaryAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF" private val primaryAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
private val otherAddress: BluetoothAddress = "11:22:33:44:55:66" private val otherAddress: BluetoothAddress = "11:22:33:44:55:66"
// Mirror of ConversationReaction.STALE_TIMEOUT_MS (private there). // Mirror of ConversationReaction.STALE_TIMEOUT (private there) — a long backstop, not the
private val staleTimeoutMs = 12_000L // 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 eventsFlow: MutableSharedFlow<Pair<BluetoothAddress, ConversationAwarenessEvent>>
private lateinit var statesFlow: MutableStateFlow<Map<BluetoothAddress, AapPodState>> private lateinit var statesFlow: MutableStateFlow<Map<BluetoothAddress, AapPodState>>
@@ -148,18 +149,18 @@ class ConversationReactionTest : BaseTest() {
} }
@Test @Test
fun `HOLD keep-alive resets the stale timer`() = runTest(UnconfinedTestDispatcher()) { fun `HOLD keep-alive resets the stale backstop`() = runTest(UnconfinedTestDispatcher()) {
val job = launchReaction() val job = launchReaction()
emit(primaryAddress, ConversationAwarenessEvent.START) emit(primaryAddress, ConversationAwarenessEvent.START)
advanceTimeBy(8_000) advanceTimeBy(staleTimeoutMs * 7 / 10)
runCurrent() runCurrent()
emit(primaryAddress, ConversationAwarenessEvent.HOLD) // resets the timer emit(primaryAddress, ConversationAwarenessEvent.HOLD) // resets the backstop
advanceTimeBy(8_000) // 8s since the HOLD — still within the window advanceTimeBy(staleTimeoutMs * 7 / 10) // <1 backstop since the HOLD — still engaged
runCurrent() runCurrent()
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) } 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() runCurrent()
verify(exactly = 1) { mediaControl.restoreMusicVolume(10) } verify(exactly = 1) { mediaControl.restoreMusicVolume(10) }
job.cancel() job.cancel()
@@ -272,4 +273,73 @@ class ConversationReactionTest : BaseTest() {
verify(exactly = 1) { mediaControl.duckMusicVolume(any()) } verify(exactly = 1) { mediaControl.duckMusicVolume(any()) }
job.cancel() 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 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 eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Nested
@@ -189,4 +192,103 @@ class PopUpReactionLogicTest : BaseTest() {
).shouldShow shouldBe false ).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 ### ### Updated by tools/release/bump.sh ###
project.versioning.major=5 project.versioning.major=5
project.versioning.minor=1 project.versioning.minor=1
project.versioning.patch=6 project.versioning.patch=7
project.versioning.build=0 project.versioning.build=0
project.versioning.type=rc project.versioning.type=rc
############################# #############################