diff --git a/CamperMonitor/Bluetooth/BluetoothManager.swift b/CamperMonitor/Bluetooth/BluetoothManager.swift index f27a945..a29c82e 100644 --- a/CamperMonitor/Bluetooth/BluetoothManager.swift +++ b/CamperMonitor/Bluetooth/BluetoothManager.swift @@ -897,7 +897,17 @@ extension BluetoothManager: CBCentralManagerDelegate { queue: queue, onUpdate: { [weak self] snapshot in self?.record(snapshot) }, 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 self?.publish { @@ -970,7 +980,13 @@ extension BluetoothManager: CBCentralManagerDelegate { .map { Date().timeIntervalSince($0) } ?? 0 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 { // Die Verbindung stand und ist weggefallen - im Fahrzeug der // Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das diff --git a/CamperMonitor/LiveActivity/LevelActivityManager.swift b/CamperMonitor/LiveActivity/LevelActivityManager.swift index caee0eb..35ef118 100644 --- a/CamperMonitor/LiveActivity/LevelActivityManager.swift +++ b/CamperMonitor/LiveActivity/LevelActivityManager.swift @@ -15,6 +15,18 @@ final class LevelActivityManager { private(set) var trackedDeviceID: UUID? @ObservationIgnored private var activity: Activity? + /// 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? + /// 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 /// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die /// Anzeige beim ersten Sperren des Bildschirms ein. @@ -25,8 +37,11 @@ final class LevelActivityManager { } func start(deviceID: UUID, deviceName: String, state: LevelState) { + pendingEnd?.cancel() + pendingEnd = nil + deviceToResume = nil guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } - end() + endActivity() let attributes = LevelActivityAttributes(deviceName: deviceName) let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil) do { @@ -46,7 +61,45 @@ final class LevelActivityManager { 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() { + 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 } trackedDeviceID = nil self.activity = nil