fix(popup): Prevent false-positive popups from BLE signal oscillation

Use profile-based identity matching and cooldown keys so two BLE
signals for the same AirPods share one cooldown timer. Revert
connection monitor to show-once-per-connection behavior.
This commit is contained in:
darken
2026-02-24 23:08:16 +01:00
parent f6b1c69edc
commit 75d2bf0169
3 changed files with 92 additions and 85 deletions
@@ -33,7 +33,7 @@ class PopUpReaction @Inject constructor(
private val bluetoothManager: BluetoothManager2, private val bluetoothManager: BluetoothManager2,
) { ) {
private val caseCoolDowns = mutableMapOf<PodDevice.Id, Instant>() private val caseCoolDowns = mutableMapOf<String, Instant>()
private fun monitorCase(): Flow<Event> = reactionSettings.showPopUpOnCaseOpen.flow private fun monitorCase(): Flow<Event> = reactionSettings.showPopUpOnCaseOpen.flow
.flatMapLatest { isEnabled -> .flatMapLatest { isEnabled ->
@@ -56,10 +56,10 @@ class PopUpReaction @Inject constructor(
} }
log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" } log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" }
val isSameDeviceWithCaseNowOpen = val isSameDeviceOrProfile = previous?.identifier == current.identifier ||
previous?.identifier == current.identifier && previous.caseLidState != current.caseLidState (previous?.meta?.profile?.id != null && previous.meta.profile?.id == current.meta.profile?.id)
val isNewDeviceWithJustOpenedCase = val isSameDeviceWithCaseNowOpen = isSameDeviceOrProfile && previous?.caseLidState != current.caseLidState
previous?.identifier != current.identifier && previous?.caseLidState != current.caseLidState val isNewDeviceWithJustOpenedCase = !isSameDeviceOrProfile && previous?.caseLidState != current.caseLidState
if (!isSameDeviceWithCaseNowOpen && !isNewDeviceWithJustOpenedCase) { if (!isSameDeviceWithCaseNowOpen && !isNewDeviceWithJustOpenedCase) {
return@mapNotNull null return@mapNotNull null
@@ -69,43 +69,46 @@ class PopUpReaction @Inject constructor(
throttleCasePopUps(current) throttleCasePopUps(current)
} }
private fun throttleCasePopUps(current: DualApplePods): Event? = when { private fun throttleCasePopUps(current: DualApplePods): Event? {
current.caseLidState == DualApplePods.LidState.OPEN -> { val cooldownKey = current.meta.profile?.id ?: current.identifier.toString()
log(TAG, INFO) { "Show popup" } return when {
current.caseLidState == DualApplePods.LidState.OPEN -> {
log(TAG, INFO) { "Show popup" }
val now = Instant.now() val now = Instant.now()
val lastShown = caseCoolDowns[current.identifier] ?: Instant.MIN val lastShown = caseCoolDowns[cooldownKey] ?: Instant.MIN
val sinceLastPop = Duration.between(lastShown, now) val sinceLastPop = Duration.between(lastShown, now)
log(TAG) { "Time since last case popup: $sinceLastPop" } log(TAG) { "Time since last case popup: $sinceLastPop" }
if (sinceLastPop >= Duration.ofSeconds(10)) { if (sinceLastPop >= Duration.ofSeconds(10)) {
caseCoolDowns[current.identifier] = Instant.now() caseCoolDowns[cooldownKey] = Instant.now()
Event.PopupShow(device = current) Event.PopupShow(device = current)
} else { } else {
log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" } log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" }
null null
}
}
current.caseLidState != DualApplePods.LidState.OPEN -> {
when (current.caseLidState) {
DualApplePods.LidState.CLOSED -> {
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
caseCoolDowns.remove(current.identifier)
}
else -> {
log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." }
caseCoolDowns[current.identifier] = Instant.now()
} }
} }
log(TAG, INFO) { "Hide popup" } current.caseLidState != DualApplePods.LidState.OPEN -> {
when (current.caseLidState) {
DualApplePods.LidState.CLOSED -> {
log(TAG, INFO) { "Lid was actively closed, resetting cooldown." }
caseCoolDowns.remove(cooldownKey)
}
Event.PopupHide() else -> {
log(TAG, WARN) { "Lid was was not actively closed, refreshing cooldown." }
caseCoolDowns[cooldownKey] = Instant.now()
}
}
log(TAG, INFO) { "Hide popup" }
Event.PopupHide()
}
else -> null
} }
else -> null
} }
private val connectionCoolDowns = mutableMapOf<BluetoothAddress, Instant>() private val connectionCoolDowns = mutableMapOf<BluetoothAddress, Instant>()
@@ -2,6 +2,7 @@ package eu.darken.capod.reaction.ui.popup
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -49,65 +50,67 @@ fun PopUpContent(
) { ) {
val context = LocalContext.current val context = LocalContext.current
Card( Box(modifier = modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 16.dp)) {
modifier = modifier.fillMaxWidth(), Card(
shape = RoundedCornerShape(24.dp), modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), shape = RoundedCornerShape(24.dp),
) { elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.padding(top = 20.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
// Header: label + signal quality Column(
Row( modifier = Modifier
modifier = Modifier.fillMaxWidth(), .fillMaxWidth()
verticalAlignment = Alignment.CenterVertically, .padding(horizontal = 20.dp)
horizontalArrangement = Arrangement.Center, .padding(top = 20.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( // Header: label + signal quality
text = device.getLabel(context), Row(
style = MaterialTheme.typography.titleLarge, modifier = Modifier.fillMaxWidth(),
maxLines = 1, verticalAlignment = Alignment.CenterVertically,
overflow = TextOverflow.Ellipsis, horizontalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f, fill = false), ) {
)
val signalText = device.getSignalQuality(context)
if (signalText.isNotBlank()) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.TwoTone.SignalCellularAlt,
contentDescription = null,
modifier = Modifier.size(12.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = signalText, text = device.getLabel(context),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
) )
val signalText = device.getSignalQuality(context)
if (signalText.isNotBlank()) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.TwoTone.SignalCellularAlt,
contentDescription = null,
modifier = Modifier.size(12.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = signalText,
style = MaterialTheme.typography.labelSmall,
)
}
} }
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Device-specific content // Device-specific content
when (device) { when (device) {
is DualPodDevice -> DualPodContent(device) is DualPodDevice -> DualPodContent(device)
is SinglePodDevice -> SinglePodContent(device) is SinglePodDevice -> SinglePodContent(device)
} }
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
// Close button // Close button
Button( Button(
onClick = onClose, onClick = onClose,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
) { ) {
Text(text = stringResource(R.string.general_close_action)) Text(text = stringResource(R.string.general_close_action))
}
} }
} }
} }
@@ -42,6 +42,7 @@ class PopUpWindow @Inject constructor(
val dm = appContext.resources.displayMetrics val dm = appContext.resources.displayMetrics
val margin = (24 * dm.density).toInt() val margin = (24 * dm.density).toInt()
width = minOf(dm.widthPixels - margin * 2, (400 * dm.density).toInt()) width = minOf(dm.widthPixels - margin * 2, (400 * dm.density).toInt())
y = (8 * dm.density).toInt()
} }
private var composeView: ComposeView? = null private var composeView: ComposeView? = null