Skip to content

AB#292768 fix(push): chain and harden the UNUserNotificationCenter delegate install - #148

Open
eligutovsky wants to merge 1 commit into
masterfrom
fix/292768-notification-center-delegate-chaining
Open

AB#292768 fix(push): chain and harden the UNUserNotificationCenter delegate install#148
eligutovsky wants to merge 1 commit into
masterfrom
fix/292768-notification-center-delegate-chaining

Conversation

@eligutovsky

@eligutovsky eligutovsky commented Aug 5, 2026

Copy link
Copy Markdown
Member

AB#292768

Description of Changes

The premise of AB#292768 is partly wrong, and I want to state that before the fix.
The bug says the SDK "silently drops" the host app's UNUserNotificationCenter delegate.
It does not. OptimoveUserNotificationCenterDelegate has captured the previous delegate and
forwarded to it since 2022, and every notification that is not ours reaches it:

Notification Today
Host app's own remote push forwarded to their delegate
Another SDK's remote push forwarded to their delegate
Host app's local notification forwarded to their delegate
Optimove push handled by us (by design — setPushReceivedInForegroundHandler / setPushOpenedHandler are the sanctioned hooks)

So the headline defect from the handoff is not real. What is real, and what this PR fixes:

1. The forwarding rule was untestable, and therefore unprotected. It lived inline in two
protocol methods that a test cannot reach. UNUserNotificationCenter.current() raises in a
test bundle — measured, not assumed:

error: bundleProxyForCurrentProcess is nil: mainBundle.bundleURL
  file:///Applications/Xcode.app/.../Library/Xcode/Agents/ (NSInternalInconsistencyException)

and neither UNNotification nor UNNotificationResponse has an initializer. So the one rule
that keeps the host app working had zero coverage. NotificationDelegateRouting now holds it
as functions of the payload alone, and 11 tests pin it down — including that registering a
foreground handler must not start swallowing other people's notifications.

The extraction is behaviour-preserving. The old code decided by a mix of push.id == 0,
userInfo["aps"] == nil, and the Bool returned by pushHandleOpen/pushHandleDismissed.
All three reduce to the same predicate — an Optimove message id is present — because both
pushHandle* methods return id != 0, and a payload with no aps cannot parse an id. The
if handled fallback to chaining is kept as a safety net rather than replaced by the router's
decision.

2. Re-installing over our own delegate nested the chain. Each layer would handle the same
notification and forward it to the host app once per layer. Not reachable through
Optimove.initialize today, which is guarded by instance == nil, but the constructor now
refuses to wrap itself so it cannot become reachable.

3. UIApplication.shared.delegate! crashed the app when the SDK was initialized before
the application delegate was set. Now logged and skipped. Same for object_getClass(delegate)!
and the duplicate force-unwrap that shadowed the klass parameter inside
swizzleDidFailRegister.

4. The install ran on whichever thread initialized the SDK. UIApplication.shared is
main-thread only, and the swizzles write file-scope IMP globals. It is now confined to the
main thread, which also removes any race on those globals.

5. Installing twice was unguarded. class_replaceMethod would return our own IMP, and the
swizzled method would then call itself until the stack ran out. Unreachable today for the same
reason as (2) — I checked, rather than repeating the handoff's claim that it was live — but the
invariant is now explicit instead of accidental.

6. The genuinely silent failure, now diagnosable. Chaining only covers a delegate installed
before the SDK. One installed after replaces ours outright, and the SDK stops seeing
notifications entirely — no push opens, no in-app tickles, no log line. The install now checks
once the current run loop turn has drained and warns, which catches the common case of the
assignment happening later in application(_:didFinishLaunchingWithOptions:). It does not
catch an assignment in a later turn (a viewDidLoad, say).

Verification

Full suite: 89 tests, 0 failures (75 before, plus the 14 added here).

Mutation-checked — the tests are not vacuous. Two mutations, isOurs forced to true and the
self-wrapping guard disabled, kill 7 of the 14:

test_foreignRemoteNotification_isForwardedToTheDelegateWeReplaced
  XCTAssertEqual failed: ("presentWithDefaultOptions") is not equal to ("forwardToExistingDelegate")
test_foreignRemoteNotification_isForwardedEvenWhenAForegroundHandlerIsRegistered
  XCTAssertEqual failed: ("invokeForegroundHandler") is not equal to ("forwardToExistingDelegate")
test_localNotificationResponse_isForwardedToTheDelegateWeReplaced
  XCTAssertEqual failed: ("handleOpen") is not equal to ("forwardToExistingDelegate")
test_delegate_doesNotChainToAnotherDelegateOfOurs
  XCTAssertTrue failed - Nesting our own delegates would handle each notification once per layer
  (+3 more)
Executed 14 tests, with 7 failures

Deliberately not changed

existingDelegate stays a strong reference, although UNUserNotificationCenter.delegate
is weak and holding it strongly can keep a delegate alive that the host app has released.
Making it weak is the more correct ownership, but it can only reduce what the host app
receives, which is the opposite of this bug, and there is no way to test the lifetime change
here. Worth doing on its own.

Optimove notifications still do not reach the host app's delegate. Forwarding them too
would double-handle in every app that already uses setPushOpenedHandler, so it is a product
decision, not a bug fix.

PushNotification.init keeps its force casts (msg["data"] as!, msgData["id"] as!,
Optimobile+Push.swift:47-49). A payload carrying custom.a.k.message without a well-formed
data.id crashes, and this init now runs on every notification the app receives, ours or not.
Out of scope here, but it should be picked up.

Breaking Changes

  • None

No public API changes. Behaviour changes are confined to cases that previously crashed or
silently did nothing.

Release Checklist

Prepare:

  • Detail any breaking changes. Breaking changes require a new major version number — none
  • Check pod lib lint passes
  • Update any relevant sections of the repository wiki pages on a branch — the push
    integration guide is worth a note that the notification-center delegate must be set
    before Optimove.initialize, which is what the new warning tells people at runtime

Bump versions in:

Bumped to 6.8.3. 6.8.1 and 6.8.2 are claimed by #144 and #147, both still open, so this
takes the next free patch number. Whichever of the three merges first should keep its number
and the others renumbered — none of them collide in CHANGELOG.md as they stand.

  • OptimoveCore.podspec
  • OptimoveNotificationServiceExtension.podspec
  • OptimoveSDK.podspec
  • OptimoveCore/Sources/Classes/Constants/SDKVersion.swift
  • README.md — n/a, contains no version reference
  • CHANGELOG.md
  • Update major version numbers in wiki (basic integration + push guides) — n/a, patch release

Integration tests

Unit tests pass (89, 0 failures). The routing rules are covered, but the parts that only exist
at runtime need a device:

  • Host app sets UNUserNotificationCenter.current().delegate before Optimove.initialize,
    then: an Optimove push opens and is tracked; the host app's own push still reaches their
    delegate; a local notification still reaches their delegate.
  • Host app sets its delegate after Optimove.initialize — expect the new warning in the log.
  • Initialize the SDK from a background thread and confirm push registration still works.

T&T Only

  • Init SDK with only T&T credentials
  • Track events

Mobile Only

  • Init SDK with all credentials
  • Track events
  • Register for push
  • Send test push
  • Receive / trigger deep link handler (In-App/Push)
  • Verify push opened handler

Release:

  • Squash and merge to master
  • Delete branch once merged

…tall

AB#292768

The delegate we install does forward foreign notifications to the one the
host app had set, but that rule lived inline in two protocol methods that
no test can reach: UNUserNotificationCenter.current() raises
bundleProxyForCurrentProcess in a test bundle, and neither UNNotification
nor UNNotificationResponse can be constructed. Extract the routing rules
into NotificationDelegateRouting, as functions of the payload alone, and
cover them.

Also, in the same install path:

- Never chain to another delegate of ours. Nesting would handle each
  notification once per layer and forward it to the host app as many
  times over. The delegate the install reads is now injected, which is
  what makes the class constructible in a test at all.
- Drop UIApplication.shared.delegate! and object_getClass(delegate)!.
  Initializing the SDK before the application delegate is set crashed;
  it now logs and skips.
- Run the install on the main thread. UIApplication.shared is main-thread
  only and the swizzles write file-scope IMP globals.
- Guard against installing twice, so class_replaceMethod cannot hand back
  our own IMP and make the swizzled method call itself.
- Warn when the delegate is replaced after the SDK was initialized, which
  takes push handling away from the SDK with no trace today.
Copilot AI review requested due to automatic review settings August 5, 2026 11:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the SDK’s push-notification integration by making UNUserNotificationCenter delegate installation safer (no self-wrapping, no force-unwrap crashes, main-thread confinement, and a diagnostic warning if replaced later), while extracting the core “is this notification ours?” routing logic into a testable unit with dedicated coverage.

Changes:

  • Extract notification routing rules into NotificationDelegateRouting and add unit tests to lock down forwarding/handling behavior.
  • Refactor OptimoveUserNotificationCenterDelegate to inject and de-nest the prior delegate and centralize routing decisions.
  • Harden push/swizzle installation: main-thread confinement, install-once guard, nil-safe delegate/class discovery, and warn if the notification center delegate is replaced after initialization; bump version to 6.8.3 and update changelog.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.

Show a summary per file
File Description
OptimoveSDK/Tests/Sources/Optimobile/NotificationDelegateRoutingTests.swift Adds focused tests that pin down routing/forwarding and self-wrapping prevention.
OptimoveSDK/Sources/Classes/Optimobile/OptimoveUserNotificationCenterDelegate.swift Refactors delegate chaining + routing and removes untestable UNUserNotificationCenter.current() dependency from init.
OptimoveSDK/Sources/Classes/Optimobile/Optimobile+Push.swift Makes push install main-thread-only, crash-safe, install-once, and adds “delegate replaced” warning.
OptimoveSDK/Sources/Classes/Optimobile/NotificationDelegateRouting.swift Introduces testable routing logic based on payload message id and action identifier.
OptimoveSDK.podspec Bumps SDK pod version to 6.8.3.
OptimoveNotificationServiceExtension.podspec Bumps NSE pod version to 6.8.3.
OptimoveCore/Sources/Classes/Constants/SDKVersion.swift Updates SDKVersion constant to 6.8.3.
OptimoveCore.podspec Bumps core pod version to 6.8.3.
CHANGELOG.md Documents the delegate-install hardening and swizzling crash fix in 6.8.3.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@eligutovsky eligutovsky changed the title fix(push): chain and harden the UNUserNotificationCenter delegate install AB#292768 fix(push): chain and harden the UNUserNotificationCenter delegate install Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unable to Route Push Notifications Correctly in iOS AppDelegate with Optimoove SDK

2 participants