diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f1d6b74..6ad51b7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -161,6 +161,16 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v7 + # The notification delegates meet only on a device, which CI does not + # have, and a mistake in how the plugin passes a call on to them is a + # crash loop in an app that has a second delegate. This compiles the + # chain that keeps them next to a model of the three that met, and runs it. + - name: Run the model of the notification delegates + run: | + xcrun swiftc -swift-version 5 -o "$RUNNER_TEMP/delegate-chain" \ + packages/flutwire/darwin/flutwire/Sources/flutwire/DelegateChain.swift \ + packages/flutwire/darwin/flutwire/Tests/DelegateChain/main.swift + "$RUNNER_TEMP/delegate-chain" - uses: subosito/flutter-action@v2 with: channel: stable diff --git a/CHANGELOG.md b/CHANGELOG.md index edf5331..658b94c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ board can be cross-checked against what actually shipped. ## [Unreleased] +### Fixed + +- On iOS and macOS, a notification that was not Flutwire's closed the app when it was tapped, or when it arrived with the app open, if the app also had Firebase Messaging or another plugin that passes calls on to the delegate it replaces. It happened from the second launch of a build, when the plugin takes the notification center at registration and again when Dart starts. In between, Firebase Messaging takes the center and keeps the plugin as the delegate to pass calls on to, so the second time the plugin remembered Firebase Messaging as its previous delegate, and each handed the call back to the other until the stack ran out. The plugin now keeps every delegate it replaces and hands a call to the newest one that the call has not reached yet. A delegate that hands the call back gets the next one down, none gets the same call twice, and the app's own delegate is reached as before + +### Internal + +- The Apple job in CI compiles the chain that keeps those delegates and runs it against a model of the three delegates that met in the crash, in each order in which they can take the center. It is the first check of the Swift half other than building it + ## [1.0.1] - 2026-10-28 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a94af18..4adea2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ flutter analyze --fatal-infos (cd packages/flutwire_cloud_functions && flutter test) ``` -The Android and Apple halves are compiled by building the example app: `flutter build apk --debug`, `flutter build ios --simulator --no-codesign` and `flutter build macos --debug`, each from `packages/flutwire/example`. The web has no native half, and `flutter build web` from the same folder is what compiles the example for it. +The Android and Apple halves are compiled by building the example app: `flutter build apk --debug`, `flutter build ios --simulator --no-codesign` and `flutter build macos --debug`, each from `packages/flutwire/example`. The web has no native half, and `flutter build web` from the same folder is what compiles the example for it. The Apple half also has a model of how it passes notifications on to the delegates it replaced, which needs no device: the first lines of `packages/flutwire/darwin/flutwire/Tests/DelegateChain/main.swift` say how to run it, and CI runs it. The public API is checked against the latest release on pub.dev, and a breaking change needs a new major version in the pubspec. To see what a change does to it: diff --git a/packages/flutwire/darwin/flutwire/Sources/flutwire/DelegateChain.swift b/packages/flutwire/darwin/flutwire/Sources/flutwire/DelegateChain.swift new file mode 100644 index 0000000..25e71ca --- /dev/null +++ b/packages/flutwire/darwin/flutwire/Sources/flutwire/DelegateChain.swift @@ -0,0 +1,74 @@ +import Foundation + +/// The delegates a plugin has taken a single-delegate object from, newest first, and the order in which a call +/// is handed to them. +/// +/// A plugin that takes a delegate over passes on whatever is not its own to the one it replaced. A second plugin +/// that does the same afterwards, as Firebase Messaging does, keeps the first as the delegate it passes on to. +/// If the first then takes the delegate back, the one it remembers is the second, which hands every call +/// straight back, and with a single remembered delegate the two pass a call between them until the stack runs +/// out. +/// +/// This keeps every delegate that was replaced. A call goes to the newest one that implements it and that the +/// call has not reached yet, so a delegate that hands the call back gets the next one down, and no delegate +/// gets the same call twice. +final class DelegateChain { + private struct Entry { + weak var delegate: Delegate? + } + + /// The delegates the call being passed on, on this thread, has reached so far. + private final class Reached { + var delegates = Set() + } + + private let lock = NSLock() + private var entries: [Entry] = [] + private let threadKey = "it.nacodestudios.flutwire.chain.\(UUID().uuidString)" + + /// Makes [delegate] the newest one, whether or not it was here before. + func remember(_ delegate: Delegate) { + lock.lock() + defer { lock.unlock() } + entries.removeAll { entry in + guard let known = entry.delegate else { return true } + return identity(known) == identity(delegate) + } + entries.insert(Entry(delegate: delegate), at: 0) + } + + /// Hands a call to the newest delegate that implements [selector] and has not had this call yet. + /// + /// Returns false when there is none, and the caller then completes the call itself. + @discardableResult + func forward(_ selector: Selector, _ call: (Delegate) -> Void) -> Bool { + let reached = reachedByThisCall() + let next = delegates().first { + !reached.delegates.contains(identity($0)) && $0.responds(to: selector) + } + guard let next else { return false } + let id = identity(next) + reached.delegates.insert(id) + defer { reached.delegates.remove(id) } + call(next) + return true + } + + private func delegates() -> [Delegate] { + lock.lock() + defer { lock.unlock() } + return entries.compactMap { $0.delegate } + } + + private func reachedByThisCall() -> Reached { + let thread = Thread.current.threadDictionary + if let reached = thread[threadKey] as? Reached { return reached } + let reached = Reached() + thread[threadKey] = reached + return reached + } + + private func identity(_ delegate: Delegate) -> ObjectIdentifier { + ObjectIdentifier(delegate as AnyObject) + } +} diff --git a/packages/flutwire/darwin/flutwire/Sources/flutwire/FlutwirePlugin.swift b/packages/flutwire/darwin/flutwire/Sources/flutwire/FlutwirePlugin.swift index 49ae46e..cf224a2 100644 --- a/packages/flutwire/darwin/flutwire/Sources/flutwire/FlutwirePlugin.swift +++ b/packages/flutwire/darwin/flutwire/Sources/flutwire/FlutwirePlugin.swift @@ -15,8 +15,8 @@ import UserNotifications /// /// The app may already have a notification delegate, for push or for its own /// local notifications. Flutwire takes the delegate over and passes everything -/// that is not its own notification on to the one it replaced, so neither side -/// notices the other. +/// that is not its own notification on to the delegates it replaced, so neither +/// side notices the other. /// /// It does that only once Flutwire has run in the build. The plugin is part of /// every build of an app that depends on Flutwire, including the ones that @@ -37,7 +37,7 @@ public final class FlutwirePlugin: NSObject, FlutterPlugin, UNUserNotificationCe private static var shared: FlutwirePlugin? private let channel: FlutterMethodChannel - private weak var previousDelegate: UNUserNotificationCenterDelegate? + private let previousDelegates = DelegateChain() private var dartIsListening = false private var launchAction: String? @@ -129,10 +129,16 @@ public final class FlutwirePlugin: NSObject, FlutterPlugin, UNUserNotificationCe /// It runs at registration, when Flutwire has run in this build before, and /// again when Dart starts, because an app may set its own delegate after /// plugins register. + /// + /// Both runs keep what they replace. Another plugin can take the delegate + /// from Flutwire in between and keep Flutwire to pass calls on to, so the + /// second run replaces that plugin and has to pass calls on to it as well. private func adoptNotificationCenter() { let center = UNUserNotificationCenter.current() guard center.delegate !== self else { return } - previousDelegate = center.delegate + if let current = center.delegate { + previousDelegates.remember(current) + } center.delegate = self } @@ -201,10 +207,12 @@ public final class FlutwirePlugin: NSObject, FlutterPlugin, UNUserNotificationCe completionHandler([.list]) return } - if let previous = previousDelegate, - previous.responds(to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:))) { + let passedOn = previousDelegates.forward( + #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:)) + ) { previous in previous.userNotificationCenter?(center, willPresent: notification, withCompletionHandler: completionHandler) - } else { + } + if !passedOn { completionHandler([]) } } @@ -215,10 +223,12 @@ public final class FlutwirePlugin: NSObject, FlutterPlugin, UNUserNotificationCe withCompletionHandler completionHandler: @escaping () -> Void ) { guard response.notification.request.identifier == Self.notificationId else { - if let previous = previousDelegate, - previous.responds(to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:))) { + let passedOn = previousDelegates.forward( + #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:)) + ) { previous in previous.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler) - } else { + } + if !passedOn { completionHandler() } return @@ -239,8 +249,9 @@ public final class FlutwirePlugin: NSObject, FlutterPlugin, UNUserNotificationCe _ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification? ) { - if let previous = previousDelegate, - previous.responds(to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:openSettingsFor:))) { + previousDelegates.forward( + #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:openSettingsFor:)) + ) { previous in previous.userNotificationCenter?(center, openSettingsFor: notification) } } diff --git a/packages/flutwire/darwin/flutwire/Tests/DelegateChain/main.swift b/packages/flutwire/darwin/flutwire/Tests/DelegateChain/main.swift new file mode 100644 index 0000000..25e991f --- /dev/null +++ b/packages/flutwire/darwin/flutwire/Tests/DelegateChain/main.swift @@ -0,0 +1,268 @@ +// A model of the notification delegates that sent the plugin into a loop, run against the real DelegateChain. +// +// A device is where these delegates meet, and CI has none. This models the three that did, each as its source +// behaves when a notification is tapped, and the notification center, which holds only one of them: +// +// App the app's own delegate. It completes the call, and it conforms to FlutterAppLifeCycleProvider, +// so Firebase Messaging leaves it in place. +// Messaging firebase_messaging 16.6.0. It replaces a delegate that is not the app's, keeps it as the one to +// pass on to, and passes every call on to that one. +// Plugin Flutwire. It handles its own notification and passes the rest on. +// +// The plugin runs twice, once as it passed calls on in 1.0.1, to a single remembered delegate, and once with +// DelegateChain, over the orders in which the three can take the center. The first has to loop where Firebase +// Messaging replaces the plugin between its two takes, or the model proves nothing. The second has to reach each +// delegate it should exactly once. +// +// From the root of the repository, on a Mac: +// +// xcrun swiftc -swift-version 5 -o /tmp/delegate-chain \ +// packages/flutwire/darwin/flutwire/Sources/flutwire/DelegateChain.swift \ +// packages/flutwire/darwin/flutwire/Tests/DelegateChain/main.swift +// /tmp/delegate-chain + +import Foundation + +@objc protocol Receiver: NSObjectProtocol { + func receive(_ call: Call) +} + +protocol LifecycleProvider {} + +final class Call: NSObject { + let own: Bool + var log: [String] = [] + var completed = 0 + var looped = false + + init(own: Bool) { + self.own = own + } + + /// Records a delegate being reached. A real loop ends when the stack does, so the model gives up instead. + func enter(_ name: String) -> Bool { + log.append(name) + if log.count > 100 { looped = true } + return !looped + } +} + +/// The notification center: one weak delegate. +final class Center { + weak var delegate: Receiver? + + func tap(own: Bool) -> Call { + let call = Call(own: own) + delegate?.receive(call) + return call + } +} + +final class App: NSObject, Receiver, LifecycleProvider { + func receive(_ call: Call) { + _ = call.enter("App") + call.completed += 1 + } +} + +final class Messaging: NSObject, Receiver { + private let name: String + private var original: Receiver? + + init(name: String = "Messaging") { + self.name = name + } + + func configure(_ center: Center) { + guard center.delegate !== self else { return } + if let current = center.delegate { + if current is LifecycleProvider { return } + original = current + } + center.delegate = self + } + + func receive(_ call: Call) { + guard call.enter(name) else { return } + if let original { + original.receive(call) + } else { + call.completed += 1 + } + } +} + +protocol Plugin: Receiver { + func takeTheCenter(_ center: Center) +} + +/// The plugin as it was in 1.0.1: one remembered delegate. +final class SingleDelegate: NSObject, Plugin { + private weak var previous: Receiver? + + func takeTheCenter(_ center: Center) { + guard center.delegate !== self else { return } + previous = center.delegate + center.delegate = self + } + + func receive(_ call: Call) { + guard call.enter("Plugin") else { return } + if call.own { + call.completed += 1 + } else if let previous { + previous.receive(call) + } else { + call.completed += 1 + } + } +} + +/// The plugin with the chain. +final class Chained: NSObject, Plugin { + private let previous = DelegateChain() + + func takeTheCenter(_ center: Center) { + guard center.delegate !== self else { return } + if let current = center.delegate { + previous.remember(current) + } + center.delegate = self + } + + func receive(_ call: Call) { + guard call.enter("Plugin") else { return } + if call.own { + call.completed += 1 + return + } + let passedOn = previous.forward(#selector(Receiver.receive(_:))) { $0.receive(call) } + if !passedOn { + call.completed += 1 + } + } +} + +struct Scenario { + let name: String + /// Whether the tap is on the plugin's own notification. + let own: Bool + /// The delegates that must each be reached once for the tap to count as passed on. + let reaches: [String] + /// Whether one remembered delegate loops in this order. + let loopsWithOne: Bool + /// Who takes the center, in order. The delegates are held in `keep`, because the center does not. + let order: (Center, Plugin, inout [AnyObject]) -> Void +} + +let scenarios: [Scenario] = [ + Scenario( + name: "first launch of a build: the app, then the plugin when Dart starts", + own: false, reaches: ["App"], loopsWithOne: false + ) { center, plugin, keep in + let app = App(), messaging = Messaging() + keep += [app, messaging] + center.delegate = app + messaging.configure(center) + plugin.takeTheCenter(center) + }, + Scenario( + name: "later launch: the plugin takes the center at registration, Firebase Messaging replaces it, " + + "the plugin takes it again when Dart starts", + own: false, reaches: ["Messaging", "App"], loopsWithOne: true + ) { center, plugin, keep in + let app = App(), messaging = Messaging() + keep += [app, messaging] + center.delegate = app + plugin.takeTheCenter(center) + messaging.configure(center) + plugin.takeTheCenter(center) + }, + Scenario( + name: "the same order, a tap on the plugin's own notification", + own: true, reaches: ["Plugin"], loopsWithOne: false + ) { center, plugin, keep in + let app = App(), messaging = Messaging() + keep += [app, messaging] + center.delegate = app + plugin.takeTheCenter(center) + messaging.configure(center) + plugin.takeTheCenter(center) + }, + Scenario( + name: "the app sets its delegate after the plugins register", + own: false, reaches: ["App"], loopsWithOne: false + ) { center, plugin, keep in + let app = App(), messaging = Messaging() + keep += [app, messaging] + plugin.takeTheCenter(center) + center.delegate = app + messaging.configure(center) + plugin.takeTheCenter(center) + }, + Scenario( + name: "the same order, a tap on the plugin's own notification", + own: true, reaches: ["Plugin"], loopsWithOne: false + ) { center, plugin, keep in + let app = App(), messaging = Messaging() + keep += [app, messaging] + plugin.takeTheCenter(center) + center.delegate = app + messaging.configure(center) + plugin.takeTheCenter(center) + }, + Scenario( + name: "Firebase Messaging first, the plugin after", + own: false, reaches: ["Messaging"], loopsWithOne: false + ) { center, plugin, keep in + let messaging = Messaging() + keep += [messaging] + messaging.configure(center) + plugin.takeTheCenter(center) + }, + Scenario( + name: "later launch, with a second plugin that keeps the delegate it replaces, on top of Firebase Messaging", + own: false, reaches: ["Other", "Messaging", "App"], loopsWithOne: true + ) { center, plugin, keep in + let app = App(), messaging = Messaging(), other = Messaging(name: "Other") + keep += [app, messaging, other] + center.delegate = app + plugin.takeTheCenter(center) + messaging.configure(center) + other.configure(center) + plugin.takeTheCenter(center) + }, +] + +func tap(_ scenario: Scenario, _ plugin: Plugin) -> Call { + let center = Center() + var keep: [AnyObject] = [plugin] + scenario.order(center, plugin, &keep) + return withExtendedLifetime(keep) { center.tap(own: scenario.own) } +} + +var failures: [String] = [] + +for scenario in scenarios { + let single = tap(scenario, SingleDelegate()) + if single.looped != scenario.loopsWithOne { + let found = single.looped ? "loops" : "does not loop" + failures.append("\(scenario.name): one remembered delegate \(found), and the model expects the opposite") + } + + let chained = tap(scenario, Chained()) + let reachedOnce = scenario.reaches.allSatisfy { name in chained.log.filter { $0 == name }.count == 1 } + if chained.looped || chained.completed != 1 || !reachedOnce { + failures.append( + "\(scenario.name): the chain went \(chained.log.joined(separator: " > ")) " + + "and completed the tap \(chained.completed) times" + ) + } +} + +if failures.isEmpty { + print("The chain reaches each delegate once in all \(scenarios.count) orders, and one remembered delegate loops where 1.0.1 did.") +} else { + failures.forEach { print("FAIL: \($0)") } + exit(1) +}