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 } }