From 0531732c36171c5d3143629f4f451f53cd6053ba Mon Sep 17 00:00:00 2001 From: Ahmed Harmouche Date: Wed, 29 Jul 2026 10:29:57 +0200 Subject: [PATCH] [ios] Fix broken async stream --- .../dashpilot/UI/DashboardView.swift | 13 ++++- dashpilot-ios/dashpilot/UI/HomeView.swift | 18 +++--- .../ViewModel/ConnectionViewModel.swift | 55 +++++++++++++------ 3 files changed, 60 insertions(+), 26 deletions(-) diff --git a/dashpilot-ios/dashpilot/UI/DashboardView.swift b/dashpilot-ios/dashpilot/UI/DashboardView.swift index e95f61c..0dd37c1 100644 --- a/dashpilot-ios/dashpilot/UI/DashboardView.swift +++ b/dashpilot-ios/dashpilot/UI/DashboardView.swift @@ -8,10 +8,14 @@ struct DashboardView: View { @Environment(ConnectionViewModel.self) var connectionVM @Environment(\.dismiss) private var dismiss + /// This view's own subscription, created once per appearance. The web + /// view's coordinator consumes it and cancels it when it is torn down. + @State private var dashStream: AsyncStream? + var body: some View { ZStack(alignment: .topLeading) { Group { - if let dashStream = connectionVM.dashMessages { + if let dashStream { switch dashboardType { case DashboardType.web.rawValue: WebDashView(url: dashboardUrl, incomingMessages: dashStream) @@ -41,7 +45,12 @@ struct DashboardView: View { .navigationBarHidden(true) .statusBar(hidden: true) .persistentSystemOverlays(.hidden) - .onAppear { UIApplication.shared.isIdleTimerDisabled = true } + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + if dashStream == nil { + dashStream = connectionVM.dashStateStream() + } + } .onDisappear { UIApplication.shared.isIdleTimerDisabled = false } } } diff --git a/dashpilot-ios/dashpilot/UI/HomeView.swift b/dashpilot-ios/dashpilot/UI/HomeView.swift index 4d407f0..dba7eba 100644 --- a/dashpilot-ios/dashpilot/UI/HomeView.swift +++ b/dashpilot-ios/dashpilot/UI/HomeView.swift @@ -30,17 +30,19 @@ struct HomeView: View { .padding(DashMetrics.screenPadding) } .navigationBarHidden(true) - .task(id: connectionVM.streamGeneration) { - guard let stream = connectionVM.dashMessages else { - dash = nil - return - } - // The for-await loop ends cleanly when the task is cancelled - // (view disappears) or the stream finishes. - for await state in stream { + .task { + // Each appearance opens its own subscription (with the latest + // state replayed), so navigating away cancels only this view's + // stream — the pipeline keeps feeding the other views. + for await state in connectionVM.dashStateStream() { dash = state } } + .onChange(of: connectionVM.connectionStatus) { _, status in + if status == .disconnected { + dash = nil + } + } } // MARK: - Header diff --git a/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift b/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift index 5cc0ed7..95583cc 100644 --- a/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift +++ b/dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift @@ -17,10 +17,19 @@ final class ConnectionViewModel { var onboardingRequested = false private(set) var connectionStatus: ConnectionStatus = .disconnected - private(set) var dashMessages: AsyncStream? private(set) var discoveredAddress: String? private(set) var discoveryError: String? + /// Most recent state of the session, replayed to new subscribers so a view + /// opened mid-session renders immediately. + private(set) var latestDashState: DashState? + + /// Live per-view subscriptions. AsyncStream is single-consumer and dies + /// with its consuming task, so every view gets its own stream and the pump + /// fans out to all of them (the Android equivalent is a shared Flow). + @ObservationIgnored + private var dashSubscribers: [UUID: AsyncStream.Continuation] = [:] + /// Live BLE manager while a DashKit session is active. Views use it to /// send control commands and the settings screen for firmware/OTA access. private(set) var bleManager: DashKitBleManager? @@ -29,14 +38,30 @@ final class ConnectionViewModel { /// error so foregrounding retries the same source, like Android. private(set) var activeSourceType: DataSourceType? - /// Bumped whenever `dashMessages` is replaced, so views can restart their - /// consuming task (`.task(id:)`) on reconnect. - private(set) var streamGeneration = 0 - private var dataSource: (any IDataSource)? private var connectTask: Task? private var startupDiscoveryStarted = false + /// Returns a stream of `DashState` updates for one view. Each caller gets + /// an independent stream, so one view leaving (its task being cancelled) + /// tears down only its own subscription — not the pipeline feeding the + /// other views. Subscriptions survive reconnects; the stream ends when the + /// consuming task is cancelled. + func dashStateStream() -> AsyncStream { + AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + let id = UUID() + dashSubscribers[id] = continuation + if let latest = latestDashState { + continuation.yield(latest) + } + continuation.onTermination = { [weak self] _ in + Task { @MainActor in + self?.dashSubscribers.removeValue(forKey: id) + } + } + } + } + /// True when a new connection may start (mirrors Android: connect is /// allowed from Disconnected and Error). private var canStartConnection: Bool { @@ -193,7 +218,7 @@ final class ConnectionViewModel { bleManager?.disconnect() bleManager = nil activeSourceType = nil - dashMessages = nil + latestDashState = nil discoveredAddress = nil discoveryError = nil connectionStatus = .disconnected @@ -220,15 +245,11 @@ final class ConnectionViewModel { startStreaming(ds, resyncDashKit: false) } - /// Consumes the data source's `CarState` stream and republishes it as - /// `DashState` for the UI; flips to `.connected` on the first message. + /// Consumes the data source's `CarState` stream, republishes it as + /// `DashState` to every subscribed view, and flips to `.connected` on the + /// first message. Subscribers are not finished when a session ends — a + /// reconnect's pump simply resumes feeding them. private func startStreaming(_ ds: any IDataSource, resyncDashKit: Bool) { - var capturedContinuation: AsyncStream.Continuation? - dashMessages = AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - capturedContinuation = continuation - } - streamGeneration += 1 - connectTask = Task { @MainActor [weak self] in guard let self else { return } UIDevice.current.isBatteryMonitoringEnabled = true @@ -252,9 +273,11 @@ final class ConnectionViewModel { phoneBattery: batteryLevel >= 0 ? Int(batteryLevel * 100) : -1, currentTime: Int64(Date().timeIntervalSince1970 * 1000) ) - capturedContinuation?.yield(state) + self.latestDashState = state + for continuation in self.dashSubscribers.values { + continuation.yield(state) + } } - capturedContinuation?.finish() } } }