Debounce Live Activity ending on BLE disconnect, auto-resume on reconnect

A dropped BLE connection ended the Live Activity immediately, which
flickered off and on during the brief, routine disconnects that happen
constantly while driving. It now waits 12s before actually ending, and
if the sensor reconnects within that window the pending end is simply
cancelled. If the disconnect does outlast the grace period and the
activity really ends, reconnecting afterwards restarts it automatically
with the last known reading, since that wasn't a deliberate user stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
fototeddy
2026-09-05 12:00:49 +02:00
co-authored by Claude Sonnet 5
parent 46a4da5e37
commit 1b06ac36d1
2 changed files with 72 additions and 3 deletions
+18 -2
View File
@@ -897,7 +897,17 @@ extension BluetoothManager: CBCentralManagerDelegate {
queue: queue, queue: queue,
onUpdate: { [weak self] snapshot in self?.record(snapshot) }, onUpdate: { [weak self] snapshot in self?.record(snapshot) },
onStateChange: { [weak self] state in onStateChange: { [weak self] state in
self?.publish { self?.linkStates[device.id] = state } self?.publish {
self?.linkStates[device.id] = state
if state == .live {
// Verbindung wieder da: ein anstehendes Ende
// verwerfen oder eine zwischenzeitlich doch schon
// beendete Live Activity wieder aufnehmen.
self?.activityManager?.handleReconnect(
deviceID: device.id,
state: self?.levelStates[device.id] ?? LevelState())
}
}
}, },
onLevelState: { [weak self] state in onLevelState: { [weak self] state in
self?.publish { self?.publish {
@@ -970,7 +980,13 @@ extension BluetoothManager: CBCentralManagerDelegate {
.map { Date().timeIntervalSince($0) } ?? 0 .map { Date().timeIntervalSince($0) } ?? 0
if let device = managed[peripheral.identifier] { if let device = managed[peripheral.identifier] {
publish { self.linkStates[device.id] = .searching } let deviceName = store.devices.first { $0.id == device.id }?.name ?? ""
publish {
self.linkStates[device.id] = .searching
// Erst nach einer Gnadenfrist wirklich beenden sonst
// flackerte die Live Activity bei jedem kurzen Funkloch.
self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName)
}
if lifetime >= stableConnection { if lifetime >= stableConnection {
// Die Verbindung stand und ist weggefallen - im Fahrzeug der // Die Verbindung stand und ist weggefallen - im Fahrzeug der
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das // Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
@@ -15,6 +15,18 @@ final class LevelActivityManager {
private(set) var trackedDeviceID: UUID? private(set) var trackedDeviceID: UUID?
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>? @ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
/// Wie lange nach einem Verbindungsabbruch gewartet wird, bevor die
/// Aktivität wirklich endet. Verbindungen fallen im Fahrzeug regelmässig
/// kurz weg ohne diese Gnadenfrist flackerte die Anzeige bei jedem
/// kurzen Funkloch aus und wieder ein.
static let disconnectGrace: TimeInterval = 12
@ObservationIgnored private var pendingEnd: Task<Void, Never>?
/// Gerät, dessen Aktivität wegen einer länger anhaltenden Trennung
/// tatsächlich beendet wurde bei der nächsten Wiederverbindung wird sie
/// automatisch neu gestartet, damit das kein bewusster Nutzer-Stop war.
@ObservationIgnored private var deviceToResume: (id: UUID, name: String)?
/// Ob gerade irgendeine Aktivität läuft die App muss dafür im /// Ob gerade irgendeine Aktivität läuft die App muss dafür im
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die /// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
/// Anzeige beim ersten Sperren des Bildschirms ein. /// Anzeige beim ersten Sperren des Bildschirms ein.
@@ -25,8 +37,11 @@ final class LevelActivityManager {
} }
func start(deviceID: UUID, deviceName: String, state: LevelState) { func start(deviceID: UUID, deviceName: String, state: LevelState) {
pendingEnd?.cancel()
pendingEnd = nil
deviceToResume = nil
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
end() endActivity()
let attributes = LevelActivityAttributes(deviceName: deviceName) let attributes = LevelActivityAttributes(deviceName: deviceName)
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil) let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
do { do {
@@ -46,7 +61,45 @@ final class LevelActivityManager {
Task { await activity.update(content) } Task { await activity.update(content) }
} }
/// Bewusstes Beenden durch den Nutzer anders als bei einem
/// Verbindungsabbruch soll das bei der nächsten Verbindung nicht
/// automatisch wieder aufleben.
func end() { func end() {
pendingEnd?.cancel()
pendingEnd = nil
deviceToResume = nil
endActivity()
}
/// BLE-Verbindung weg: nicht sofort beenden, sondern erst nach einer
/// kurzen Gnadenfrist meldet sich das Gerät vorher zurück
/// (`handleReconnect`), passiert gar nichts.
func handleDisconnect(deviceID: UUID, deviceName: String) {
guard trackedDeviceID == deviceID, activity != nil, pendingEnd == nil else { return }
pendingEnd = Task { [weak self] in
try? await Task.sleep(for: .seconds(Self.disconnectGrace))
guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return }
self.deviceToResume = (deviceID, deviceName)
self.endActivity()
self.pendingEnd = nil
}
}
/// BLE-Verbindung wieder da: ein noch anstehendes Ende verwerfen, oder
/// falls die Gnadenfrist schon abgelaufen und die Aktivität wirklich
/// beendet war sie mit dem letzten bekannten Stand neu starten.
func handleReconnect(deviceID: UUID, state: LevelState) {
if pendingEnd != nil, trackedDeviceID == deviceID {
pendingEnd?.cancel()
pendingEnd = nil
return
}
guard let resume = deviceToResume, resume.id == deviceID else { return }
deviceToResume = nil
start(deviceID: deviceID, deviceName: resume.name, state: state)
}
private func endActivity() {
guard let activity else { return } guard let activity else { return }
trackedDeviceID = nil trackedDeviceID = nil
self.activity = nil self.activity = nil