From 1dcd96450f2e476cfbb3fbed7a42bc0e25856be4 Mon Sep 17 00:00:00 2001 From: Andrea Valenzano Date: Wed, 21 Oct 2026 10:06:34 +0200 Subject: [PATCH] fix(connectivity_plus): emit the update missed in the background once the app is active (iOS) Since #3797 the iOS plugin drops every connectivity update that arrives while UIApplication.applicationState is .background, to avoid sending on a FlutterEngine whose shell is gone. Nothing emits it later: a Dart listener that stays subscribed gets no new onListen, so after a network change made while the app was in the background (for example turning Wi-Fi off in Settings and coming back) onConnectivityChanged keeps reporting the old connectivity until the next path change. Remember that an update was dropped, and emit the current connectivity on UIApplication.didBecomeActiveNotification. The current path rather than the dropped one, since a newer update may still be queued. Updates that arrive while the app is active or inactive still go out at once and clear the flag, as do onCancel and detachFromEngine. Nothing is sent while the app is in the background, as before. --- .../example/ios/RunnerTests/RunnerTests.swift | 163 +++++++++++++++++- .../ConnectivityPlusPlugin.swift | 39 ++++- 2 files changed, 195 insertions(+), 7 deletions(-) diff --git a/packages/connectivity_plus/connectivity_plus/example/ios/RunnerTests/RunnerTests.swift b/packages/connectivity_plus/connectivity_plus/example/ios/RunnerTests/RunnerTests.swift index 86a7c3b1b6..d9afbbeaf4 100644 --- a/packages/connectivity_plus/connectivity_plus/example/ios/RunnerTests/RunnerTests.swift +++ b/packages/connectivity_plus/connectivity_plus/example/ios/RunnerTests/RunnerTests.swift @@ -2,11 +2,168 @@ import Flutter import UIKit import XCTest +@testable import connectivity_plus + +private class FakeConnectivityProvider: NSObject, ConnectivityProvider { + var currentConnectivityTypes: [ConnectivityType] = [.wifi] + var connectivityUpdateHandler: ConnectivityUpdateHandler? + + func start() {} + + func stop() {} + + func pathChanged(to connectivityTypes: [ConnectivityType]) { + currentConnectivityTypes = connectivityTypes + connectivityUpdateHandler?(connectivityTypes) + } +} + class RunnerTests: XCTestCase { + private var provider: FakeConnectivityProvider! + private var notificationCenter: NotificationCenter! + private var applicationState: UIApplication.State = .active + private var events: [[String]] = [] + private var plugin: ConnectivityPlusPlugin! + + override func setUp() { + super.setUp() + provider = FakeConnectivityProvider() + notificationCenter = NotificationCenter() + applicationState = .active + events = [] + plugin = ConnectivityPlusPlugin( + connectivityProvider: provider, + applicationState: { [unowned self] in self.applicationState }, + notificationCenter: notificationCenter) + listen() + } + + /// The plugin emits from `DispatchQueue.main.async`: wait until the blocks queued so far have + /// run. + private func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 5) + } + + private func listen() { + _ = plugin.onListen(withArguments: nil) { [unowned self] event in + self.events.append(event as! [String]) + } + drainMainQueue() + } + + private func becomeActive() { + applicationState = .active + notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil) + drainMainQueue() + } + + func testEmitsTheCurrentConnectivityOnListen() { + XCTAssertEqual(events, [["wifi"]]) + } + + func testEmitsChangesWhileInTheForeground() { + provider.pathChanged(to: [.cellular]) + drainMainQueue() + + XCTAssertEqual(events, [["wifi"], ["mobile"]]) + } + + func testDoesNotEmitWhileInTheBackground() { + applicationState = .background + provider.pathChanged(to: [.cellular]) + drainMainQueue() + + XCTAssertEqual(events, [["wifi"]]) + } + + func testEmitsTheLatestChangeMissedInTheBackgroundOnceActive() { + applicationState = .background + provider.pathChanged(to: [.cellular]) + provider.pathChanged(to: [.none]) + drainMainQueue() + + becomeActive() + + XCTAssertEqual(events, [["wifi"], ["none"]]) + } + + func testEmitsTheCurrentPathOnceActiveWhenANewerUpdateIsStillQueued() { + applicationState = .background + provider.pathChanged(to: [.cellular]) + drainMainQueue() + // NWPathMonitor already reports the new path; its update has not reached the plugin yet. + provider.currentConnectivityTypes = [.wifi] + + becomeActive() + + XCTAssertEqual(events, [["wifi"], ["wifi"]]) + } + + func testEmitsAMissedChangeOnlyOnce() { + applicationState = .background + provider.pathChanged(to: [.cellular]) + drainMainQueue() + + becomeActive() + becomeActive() + + XCTAssertEqual(events, [["wifi"], ["mobile"]]) + } + + func testDoesNotEmitOnBecomingActiveWithoutAMissedChange() { + becomeActive() + + XCTAssertEqual(events, [["wifi"]]) + } + + func testDropsAMissedChangeSupersededInTheForeground() { + applicationState = .background + provider.pathChanged(to: [.cellular]) + drainMainQueue() + applicationState = .inactive + provider.pathChanged(to: [.wifi]) + drainMainQueue() - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + becomeActive() + + XCTAssertEqual(events, [["wifi"], ["wifi"]]) } + func testDoesNotEmitAMissedChangeAfterCancel() { + applicationState = .background + provider.pathChanged(to: [.cellular]) + drainMainQueue() + + _ = plugin.onCancel(withArguments: nil) + becomeActive() + + XCTAssertEqual(events, [["wifi"]]) + } + + func testEmitsAMissedChangeAfterListeningAgain() { + _ = plugin.onCancel(withArguments: nil) + listen() + applicationState = .background + provider.pathChanged(to: [.cellular]) + drainMainQueue() + + becomeActive() + + XCTAssertEqual(events, [["wifi"], ["wifi"], ["mobile"]]) + } + + func testEmitsTheConnectivityOnceActiveWhenListeningInTheBackground() { + _ = plugin.onCancel(withArguments: nil) + events = [] + applicationState = .background + listen() + + XCTAssertEqual(events, []) + + becomeActive() + + XCTAssertEqual(events, [["wifi"]]) + } } diff --git a/packages/connectivity_plus/connectivity_plus/ios/connectivity_plus/Sources/connectivity_plus/ConnectivityPlusPlugin.swift b/packages/connectivity_plus/connectivity_plus/ios/connectivity_plus/Sources/connectivity_plus/ConnectivityPlusPlugin.swift index 9577e39bdb..2773a4f13a 100644 --- a/packages/connectivity_plus/connectivity_plus/ios/connectivity_plus/Sources/connectivity_plus/ConnectivityPlusPlugin.swift +++ b/packages/connectivity_plus/connectivity_plus/ios/connectivity_plus/Sources/connectivity_plus/ConnectivityPlusPlugin.swift @@ -7,14 +7,30 @@ import UIKit public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { private let connectivityProvider: ConnectivityProvider + private let applicationState: @MainActor () -> UIApplication.State private var eventSink: FlutterEventSink? + /// Whether an update was dropped while the app was in the background: the current + /// connectivity is emitted once the app becomes active again. Only touched on the main queue. + private var missedUpdate = false - init(connectivityProvider: ConnectivityProvider) { + init( + connectivityProvider: ConnectivityProvider, + applicationState: @escaping @MainActor () -> UIApplication.State = { + UIApplication.shared.applicationState + }, + notificationCenter: NotificationCenter = .default + ) { self.connectivityProvider = connectivityProvider + self.applicationState = applicationState super.init() self.connectivityProvider.connectivityUpdateHandler = { [weak self] connectivityTypes in self?.connectivityUpdateHandler(connectivityTypes: connectivityTypes) } + notificationCenter.addObserver( + self, + selector: #selector(applicationDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil) } public static func register(with registrar: FlutterPluginRegistrar) { @@ -38,6 +54,7 @@ public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandl public func detachFromEngine(for registrar: FlutterPluginRegistrar) { eventSink = nil + missedUpdate = false connectivityProvider.stop() } @@ -90,16 +107,30 @@ public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandl // NWPathMonitor can fire while the app is in the background, after the // FlutterEngine's shell has been torn down. Calling eventSink in that // state triggers an NSAssertion in -[FlutterEngine sendOnChannel:] and - // crashes the app with SIGABRT. Skip the emission until the app is - // foregrounded again; the next onListen / check call will resync state. - guard UIApplication.shared.applicationState != .background else { return } + // crashes the app with SIGABRT. Skip the emission, and emit the current + // connectivity once the app becomes active again: a listener that stays + // subscribed gets no new onListen, so it would otherwise keep the state + // from before. + guard self.applicationState() != .background else { + self.missedUpdate = true + return + } + self.missedUpdate = false eventSink(self.statusFrom(connectivityTypes: connectivityTypes)) } } + @objc private func applicationDidBecomeActive() { + guard missedUpdate, let eventSink = eventSink else { return } + missedUpdate = false + // The current path rather than the dropped one: a newer update may still be queued. + eventSink(statusFrom(connectivityTypes: connectivityProvider.currentConnectivityTypes)) + } + public func onCancel(withArguments _: Any?) -> FlutterError? { connectivityProvider.stop() eventSink = nil + missedUpdate = false return nil } }