forked from fritob/Camper-Monitor
LevelActivityManager.update() stiess bei jeder Messung sofort activity.update() an – der Neigungsmesser liefert per Notify oder 0,5s-Poll deutlich öfter als einmal pro Sekunde. Bei dieser Frequenz drosselt iOS lokale Live-Activity-Aktualisierungen zunehmend selbst, was in CarPlay auffiel: Das Dashboard zeigt neue Werte dort spürbar verzögert an, weil es ohnehin zurückhaltender aktualisiert als Sperrbildschirm oder Dynamic Island und die Drosselung dadurch sichtbar wird. Jetzt wird höchstens einmal pro Sekunde tatsächlich aktualisiert; Messungen innerhalb der Sperrfrist werden nicht verworfen, sondern der neueste Stand fürs Fristende vorgemerkt (wie schon beim Verbindungsabbruch mit `pendingEnd` gehandhabt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
168 lines
7.3 KiB
Swift
168 lines
7.3 KiB
Swift
import ActivityKit
|
||
import Foundation
|
||
import Observation
|
||
|
||
/// Startet, aktualisiert und beendet die Live Activity des Neigungsmessers.
|
||
///
|
||
/// Es läuft höchstens eine Aktivität gleichzeitig – mehr als einen
|
||
/// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 26 zeigt
|
||
/// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein
|
||
/// eigenes CarPlay-App-Target braucht es dafür nicht – die Inhalte kommen
|
||
/// aber nicht von der Sperrbildschirm-Ansicht, sondern von der
|
||
/// `.small`-Aktivitätsfamilie (siehe `VanControlLiveActivity`).
|
||
@Observable
|
||
final class LevelActivityManager {
|
||
private(set) var trackedDeviceID: UUID?
|
||
@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
|
||
|
||
/// Wie oft die Live Activity höchstens tatsächlich aktualisiert wird.
|
||
///
|
||
/// Der Neigungsmesser liefert Werte deutlich öfter (abonniert bei jeder
|
||
/// Änderung, sonst alle 0,5 s abgefragt). Stösst man ActivityKit im
|
||
/// selben Takt an, drosselt iOS nach kurzer Zeit selbst immer stärker –
|
||
/// die Anzeige hinkt dann sichtbar hinterher, in CarPlay besonders
|
||
/// deutlich, weil das Dashboard ohnehin zurückhaltender aktualisiert als
|
||
/// Sperrbildschirm oder Dynamic Island. Ein fester Mindestabstand hält
|
||
/// die tatsächlichen Aktualisierungen unter dem, was das System duldet.
|
||
static let minimumUpdateInterval: TimeInterval = 1
|
||
|
||
@ObservationIgnored private var lastPushedAt: Date?
|
||
/// Ein während der Sperrfrist aufgelaufener Messwert, der nachgeholt
|
||
/// wird, sobald die Frist um ist – sonst bliebe die Anzeige bei
|
||
/// schnellen Änderungen auf einem Zwischenstand stehen.
|
||
@ObservationIgnored private var pendingUpdate: Task<Void, Never>?
|
||
|
||
@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
|
||
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
|
||
/// Anzeige beim ersten Sperren des Bildschirms ein.
|
||
var isActive: Bool { trackedDeviceID != nil }
|
||
|
||
func isActive(for deviceID: UUID) -> Bool {
|
||
trackedDeviceID == deviceID && activity != nil
|
||
}
|
||
|
||
func start(deviceID: UUID, deviceName: String, state: LevelState) {
|
||
pendingEnd?.cancel()
|
||
pendingEnd = nil
|
||
deviceToResume = nil
|
||
pendingUpdate?.cancel()
|
||
pendingUpdate = nil
|
||
lastPushedAt = nil
|
||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||
endActivity()
|
||
let attributes = LevelActivityAttributes(deviceName: deviceName)
|
||
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
|
||
do {
|
||
activity = try Activity.request(attributes: attributes, content: content)
|
||
trackedDeviceID = deviceID
|
||
lastPushedAt = Date()
|
||
} catch {
|
||
// Kann z.B. an fehlender Nutzerfreigabe liegen – dann bleibt es
|
||
// einfach bei der Anzeige in der App, es gibt sonst nichts zu tun.
|
||
}
|
||
}
|
||
|
||
/// Wird bei jeder neuen Messung aufgerufen, unabhängig davon, ob gerade
|
||
/// eine Aktivität läuft – kein Aufwand ohne aktive Anzeige.
|
||
///
|
||
/// Innerhalb der Sperrfrist wird nicht einfach verworfen, sondern der
|
||
/// neueste Stand für ihr Ende vorgemerkt – ein älterer vorgemerkter
|
||
/// Aufruf verfällt dabei. So kommt bei schnellen Änderungen immer der
|
||
/// zuletzt gemessene Wert an, nur eben gesammelt statt einzeln.
|
||
func update(deviceID: UUID, state: LevelState) {
|
||
guard trackedDeviceID == deviceID, activity != nil else { return }
|
||
pendingUpdate?.cancel()
|
||
pendingUpdate = nil
|
||
|
||
let elapsed = lastPushedAt.map { Date().timeIntervalSince($0) } ?? .infinity
|
||
guard elapsed < Self.minimumUpdateInterval else {
|
||
push(state)
|
||
return
|
||
}
|
||
let delay = Self.minimumUpdateInterval - elapsed
|
||
pendingUpdate = Task { [weak self] in
|
||
try? await Task.sleep(for: .seconds(delay))
|
||
guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return }
|
||
self.pendingUpdate = nil
|
||
self.push(state)
|
||
}
|
||
}
|
||
|
||
private func push(_ state: LevelState) {
|
||
guard let activity else { return }
|
||
lastPushedAt = Date()
|
||
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
|
||
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() {
|
||
pendingUpdate?.cancel()
|
||
pendingUpdate = nil
|
||
guard let activity else { return }
|
||
trackedDeviceID = nil
|
||
self.activity = nil
|
||
Task { await activity.end(nil, dismissalPolicy: .immediate) }
|
||
}
|
||
|
||
private static func contentState(from state: LevelState) -> LevelActivityAttributes.ContentState {
|
||
LevelActivityAttributes.ContentState(
|
||
pitch: state.pitch,
|
||
roll: state.roll,
|
||
isLevel: state.isLevel,
|
||
instruction: state.instruction,
|
||
isCalibrated: state.isCalibrated,
|
||
updatedAt: Date()
|
||
)
|
||
}
|
||
}
|