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