Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Delegate: NSObjectProtocol> {
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<ObjectIdentifier>()
}

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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<UNUserNotificationCenterDelegate>()
private var dartIsListening = false
private var launchAction: String?

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

Expand Down Expand Up @@ -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([])
}
}
Expand All @@ -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
Expand All @@ -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)
}
}
Expand Down
Loading
Loading