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 caseCoolDowns = mutableMapOf<PodDevice.Id, Instant>()
private val caseCoolDowns = mutableMapOf<String, Instant>()
private fun monitorCase(): Flow<Event> = reactionSettings.showPopUpOnCaseOpen.flow
.flatMapLatest { isEnabled ->
@@ -56,10 +56,10 @@ class PopUpReaction @Inject constructor(
}
log(TAG, VERBOSE) { "previous-id=${previous?.identifier}, current-id=${current.identifier}" }
val isSameDeviceWithCaseNowOpen =
previous?.identifier == current.identifier && previous.caseLidState != current.caseLidState
val isNewDeviceWithJustOpenedCase =
previous?.identifier != current.identifier && previous?.caseLidState != current.caseLidState
val isSameDeviceOrProfile = previous?.identifier == current.identifier ||
(previous?.meta?.profile?.id != null && previous.meta.profile?.id == current.meta.profile?.id)
val isSameDeviceWithCaseNowOpen = isSameDeviceOrProfile && previous?.caseLidState != current.caseLidState
val isNewDeviceWithJustOpenedCase = !isSameDeviceOrProfile && previous?.caseLidState != current.caseLidState
if (!isSameDeviceWithCaseNowOpen && !isNewDeviceWithJustOpenedCase) {
return@mapNotNull null
@@ -69,43 +69,46 @@ class PopUpReaction @Inject constructor(
throttleCasePopUps(current)
}
private fun throttleCasePopUps(current: DualApplePods): Event? = when {
current.caseLidState == DualApplePods.LidState.OPEN -> {
log(TAG, INFO) { "Show popup" }
private fun throttleCasePopUps(current: DualApplePods): Event? {
val cooldownKey = current.meta.profile?.id ?: current.identifier.toString()
return when {
current.caseLidState == DualApplePods.LidState.OPEN -> {
log(TAG, INFO) { "Show popup" }
val now = Instant.now()
val lastShown = caseCoolDowns[current.identifier] ?: Instant.MIN
val sinceLastPop = Duration.between(lastShown, now)
log(TAG) { "Time since last case popup: $sinceLastPop" }
val now = Instant.now()
val lastShown = caseCoolDowns[cooldownKey] ?: Instant.MIN
val sinceLastPop = Duration.between(lastShown, now)
log(TAG) { "Time since last case popup: $sinceLastPop" }
if (sinceLastPop >= Duration.ofSeconds(10)) {
caseCoolDowns[current.identifier] = Instant.now()
Event.PopupShow(device = current)
} else {
log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" }
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()
if (sinceLastPop >= Duration.ofSeconds(10)) {
caseCoolDowns[cooldownKey] = Instant.now()
Event.PopupShow(device = current)
} else {
log(TAG, INFO) { "Case popup is still on cooldown: $sinceLastPop" }
null
}
}
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>()
@@ -2,6 +2,7 @@ package eu.darken.capod.reaction.ui.popup
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -49,65 +50,67 @@ fun PopUpContent(
) {
val context = LocalContext.current
Card(
modifier = modifier.fillMaxWidth(),
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,
Box(modifier = modifier.padding(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 16.dp)) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
) {
// Header: label + signal quality
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.padding(top = 20.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = device.getLabel(context),
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))
// Header: label + signal quality
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Text(
text = signalText,
style = MaterialTheme.typography.labelSmall,
text = device.getLabel(context),
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
when (device) {
is DualPodDevice -> DualPodContent(device)
is SinglePodDevice -> SinglePodContent(device)
}
// Device-specific content
when (device) {
is DualPodDevice -> DualPodContent(device)
is SinglePodDevice -> SinglePodContent(device)
}
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(20.dp))
// Close button
Button(
onClick = onClose,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
) {
Text(text = stringResource(R.string.general_close_action))
// Close button
Button(
onClick = onClose,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
) {
Text(text = stringResource(R.string.general_close_action))
}
}
}
}
@@ -42,6 +42,7 @@ class PopUpWindow @Inject constructor(
val dm = appContext.resources.displayMetrics
val margin = (24 * dm.density).toInt()
width = minOf(dm.widthPixels - margin * 2, (400 * dm.density).toInt())
y = (8 * dm.density).toInt()
}
private var composeView: ComposeView? = null