Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"]])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -38,6 +54,7 @@ public class ConnectivityPlusPlugin: NSObject, FlutterPlugin, FlutterStreamHandl

public func detachFromEngine(for registrar: FlutterPluginRegistrar) {
eventSink = nil
missedUpdate = false
connectivityProvider.stop()
}

Expand Down Expand Up @@ -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
}
}
Loading