Skip to content

feat(AutoPushTracking): Injection + KVO wiring for KlaviyoNotificationDelegate#587

Open
glenn-klaviyo wants to merge 4 commits into
feat/auto-push-trackingfrom
gb/mage-657-auto-inject-delegate-kvo-plist
Open

feat(AutoPushTracking): Injection + KVO wiring for KlaviyoNotificationDelegate#587
glenn-klaviyo wants to merge 4 commits into
feat/auto-push-trackingfrom
gb/mage-657-auto-inject-delegate-kvo-plist

Conversation

@glenn-klaviyo
Copy link
Copy Markdown

@glenn-klaviyo glenn-klaviyo commented May 29, 2026

Description

Phase 1 ticket #2 of the Automatic Push Open Tracking plan (MAGE-657), scoped to the injection and KVO wiring layer. Delegates the forwarding-cycle guard, OnceCallback, and full didReceive/willPresent implementations to follow-on PRs.

Due Diligence

  • I have tested this on a simulator or a physical device.
  • I have added sufficient unit/integration tests of my changes.
  • I have adjusted or added new test cases to team test docs, if applicable.
  • I am confident these changes are compatible with all iOS and XCode versions the SDK currently supports.

Release/Versioning Considerations

  • Patch Contains internal changes or backwards-compatible bug fixes.
  • Minor Contains changes to the public API.
  • Major Contains breaking changes.
  • Contains readme or migration guide changes.
  • This is planned work for an upcoming release.

Changelog / Code Overview

KlaviyoNotificationDelegate.swift

  • New UserNotificationCenterProtocol — surfaces delegate and observeDelegate(using:) so tests can substitute a mock without the app-bundle context UNUserNotificationCenter.current() requires. UNUserNotificationCenter gets a retroactive conformance; MainActor.assumeIsolated rationale is documented where the KVO closure lives.
  • injectIfEnabled() — reads opt-in flag and center from KlaviyoSwiftEnvironment, installs proxy when enabled. No plist or center references in the method body itself — both flow through the environment.
  • inject(into:) — private; captures existingDelegate, sets proxy as delegate, installs change observer that re-injects if host later overwrites the delegate (SceneDelegate scenario).

KlaviyoSwiftEnvironment.swift

  • injectNotificationDelegate — calls injectIfEnabled() on the main actor; wired into initialize(with:).
  • isAutomaticPushTrackingEnabled — reads klaviyo_automatic_push_tracking plist key in production; stubbable in tests.
  • notificationCenter — returns UNUserNotificationCenter.current() in production; stubbable in tests.

Klaviyo.swift — one-liner: calls klaviyoSwiftEnvironment.injectNotificationDelegate() from initialize(with:).

KlaviyoNotificationDelegateTests.swift — 6 automated tests via MockNotificationCenter:

  • initialize(with:) calls injectNotificationDelegate exactly once
  • No-op when tracking is disabled (plist gate)
  • Proxy set as delegate after injection
  • Prior delegate captured as existingDelegate
  • Idempotent on repeated calls
  • KVO observer re-installs proxy after host reassignment

Note: The klaviyo_automatic_push_tracking plist key itself (key present + true → injection runs) is verified manually in the example app — Bundle.main in the SPM test runner never carries it.

Test Plan

  1. Build: xcodebuild build -scheme klaviyo-swift-sdk-Package -destination "platform=iOS Simulator,name=iPhone 17 Pro"
  2. Tests: make test-library
  3. Manual — in SPMExample with klaviyo_automatic_push_tracking = YES in Info.plist:
    • Cold launch → breakpoint in inject(into:) should hit at initialize(with:) time
    • Add a second UNUserNotificationCenter.delegate assignment in SceneDelegate → proxy should be re-installed (verify via breakpoint in KVO closure)
    • Remove plist key → breakpoint should not hit

Related Issues/Tickets

Partially addresses MAGE-657
Blocked by MAGE-649 ✅
Blocks MAGE-659, MAGE-660, MAGE-661

Summary by CodeRabbit

  • New Features

    • Notification delegate injection now occurs during SDK initialization.
    • Automatic push tracking is controllable via Info.plist.
    • Delegate handling preserves the app's existing delegate and automatically re-injects the proxy if the app overwrites it.
  • Tests

    • Added comprehensive tests for injection wiring, disabled-tracking behavior, idempotence, prior-delegate forwarding, and re-injection.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 29, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Enterprise

Run ID: 5b20f50c-aa89-4f77-818d-96ad2a3fe25c

📥 Commits

Reviewing files that changed from the base of the PR and between 947eb8b and 669ef61.

📒 Files selected for processing (1)
  • Sources/KlaviyoSwift/KlaviyoNotificationDelegate.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/KlaviyoSwift/KlaviyoNotificationDelegate.swift

📝 Walkthrough

Walkthrough

Adds a testable UNUserNotificationCenter abstraction, implements gated, idempotent KlaviyoNotificationDelegate injection with delegate-change observation, wires injection into the production environment and SDK initialization, and adds unit tests plus test-environment wiring.

Changes

Push Notification Delegate Injection

Layer / File(s) Summary
Notification Center Protocol & Abstraction
Sources/KlaviyoSwift/KlaviyoNotificationDelegate.swift
UserNotificationCenterProtocol abstracts delegate access and observation; UNUserNotificationCenter extension implements KVO-based observation to enable testable delegate tracking on the MainActor.
Delegate Injection & Observation Implementation
Sources/KlaviyoSwift/KlaviyoNotificationDelegate.swift
KlaviyoNotificationDelegate adds existingDelegate and centerObservation; @MainActor static func injectIfEnabled() gates installation on isAutomaticPushTrackingEnabled() and inject(into:) idempotently installs the proxy, captures prior delegate, and observes future delegate changes to re-install the proxy.
Environment Configuration & SDK Initialization
Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift, Sources/KlaviyoSwift/Klaviyo.swift
KlaviyoSwiftEnvironment adds injectNotificationDelegate, isAutomaticPushTrackingEnabled, and notificationCenter closures; production environment schedules injection on @MainActor, reads klaviyo_automatic_push_tracking from Info.plist, and returns UNUserNotificationCenter.current(); Klaviyo.initialize() now calls injectNotificationDelegate().
Test Mocks, Suite & Validation
Tests/KlaviyoSwiftTests/KlaviyoNotificationDelegateTests.swift, Tests/KlaviyoSwiftTests/TestData.swift
Adds MockUNDelegate and MockNotificationCenter test doubles and expands tests to cover initialization wiring, disabled-mode no-op, enabled-mode proxy installation and idempotence, and re-injection after host delegate reassignment; test environment configured with no-op injection and disabled tracking.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • ab1470
  • belleklaviyo

Poem

🐰 I tunneled to tests and left a neat trail,
A proxy that watches each delegate tale,
With protocols stitched and a MainActor cheer,
It hops back to safety when hosts reappear,
Hooray—code snug as a carrot, sincere!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main change: implementing injection and KVO wiring for KlaviyoNotificationDelegate as part of automatic push tracking.
Description check ✅ Passed The description covers all template sections with sufficient detail: feature context, due diligence checklist, release considerations, comprehensive changelog explaining key changes, and a concrete test plan.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gb/mage-657-auto-inject-delegate-kvo-plist

Comment @coderabbitai help to get the list of available commands and usage tips.

@glenn-klaviyo glenn-klaviyo requested review from a team and removed request for a team May 29, 2026 13:42
@glenn-klaviyo
Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 29, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift (1)

73-75: ⚡ Quick win

Extract the plist key into a constant.

"klaviyo_automatic_push_tracking" is a magic string that is duplicated — it also appears inside the log message in KlaviyoNotificationDelegate.injectIfEnabled(). A shared static constant avoids drift between the two references.

♻️ Suggested constant
+private enum InfoPlistKey {
+    static let automaticPushTracking = "klaviyo_automatic_push_tracking"
+}
+
 ...
             isAutomaticPushTrackingEnabled: {
-                Bundle.main.object(forInfoDictionaryKey: "klaviyo_automatic_push_tracking") as? Bool == true
+                Bundle.main.object(forInfoDictionaryKey: InfoPlistKey.automaticPushTracking) as? Bool == true
             },

As per coding guidelines: "Avoid magic strings/numbers, preferring constants, enums and static properties in Swift."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift` around lines 73 - 75,
Extract the plist key "klaviyo_automatic_push_tracking" into a single shared
constant and replace the literal occurrences: define a static let (e.g.,
KlaviyoSwiftEnvironment.klaviyoAutomaticPushTrackingKey or a shared KlaviyoKeys
struct) and use it inside the isAutomaticPushTrackingEnabled closure in
KlaviyoSwiftEnvironment and in KlaviyoNotificationDelegate.injectIfEnabled()’s
log message; update both references to use the new constant to avoid the
duplicated magic string.
Tests/KlaviyoSwiftTests/KlaviyoNotificationDelegateTests.swift (1)

50-53: 💤 Low value

Consider resetting KlaviyoNotificationDelegate.shared between tests.

shared is a process-wide singleton whose existingDelegate/centerObservation persist across tests, while only klaviyoSwiftEnvironment is reset in init(). The current assertions happen to be order-independent, but a future test could become flaky from leaked state. A small teardown that clears the singleton's captured state would harden the suite.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/KlaviyoSwiftTests/KlaviyoNotificationDelegateTests.swift` around lines
50 - 53, The tests should clear process-wide singleton state for
KlaviyoNotificationDelegate between runs: add a teardown (or modify
KlaviyoNotificationDelegateTests.init) to reset
KlaviyoNotificationDelegate.shared by clearing its captured properties (set
shared.existingDelegate = nil and shared.centerObservation = nil) or implement
and call a small reset() on KlaviyoNotificationDelegate that does this; keep the
existing klaviyoSwiftEnvironment = KlaviyoSwiftEnvironment.test() initialization
but ensure the singleton is cleared so tests cannot leak state.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/KlaviyoSwift/KlaviyoNotificationDelegate.swift`:
- Around line 82-88: The log message in the iOS 14 branch of the
klaviyoSwiftEnvironment.isAutomaticPushTrackingEnabled() guard exceeds the
110-char SwiftLint limit; update the call that uses Logger.notifications.info so
the message is split into shorter parts (either by breaking into two
Logger.notifications.info calls with a shorter first-line message and a
follow-up line, or by concatenating two shorter string literals) to ensure no
individual string literal exceeds 110 characters while preserving the full
message content and context.
- Around line 25-34: The current observeDelegate(using:) relies on KVO for
UNUserNotificationCenter.delegate and uses MainActor.assumeIsolated, which is
unsupported and can crash; replace the KVO approach in the
UNUserNotificationCenter extension by reading the delegate on the main actor and
invoking the handler when it changes (avoid MainActor.assumeIsolated).
Concretely, implement observeDelegate(using:) to capture the current delegate by
performing Task { `@MainActor` in let current = self.delegate; store it in a small
wrapper object and start a main-actor-safe watcher (e.g., observe app lifecycle
notifications like UIApplication.didBecomeActive or schedule a lightweight
debounce check on the main actor) that re-reads self.delegate on `@MainActor` and
calls the provided handler if the delegate reference differs; ensure the
returned AnyObject is a token that cancels the watcher and that no KVO APIs or
assumeIsolated are used.

---

Nitpick comments:
In `@Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift`:
- Around line 73-75: Extract the plist key "klaviyo_automatic_push_tracking"
into a single shared constant and replace the literal occurrences: define a
static let (e.g., KlaviyoSwiftEnvironment.klaviyoAutomaticPushTrackingKey or a
shared KlaviyoKeys struct) and use it inside the isAutomaticPushTrackingEnabled
closure in KlaviyoSwiftEnvironment and in
KlaviyoNotificationDelegate.injectIfEnabled()’s log message; update both
references to use the new constant to avoid the duplicated magic string.

In `@Tests/KlaviyoSwiftTests/KlaviyoNotificationDelegateTests.swift`:
- Around line 50-53: The tests should clear process-wide singleton state for
KlaviyoNotificationDelegate between runs: add a teardown (or modify
KlaviyoNotificationDelegateTests.init) to reset
KlaviyoNotificationDelegate.shared by clearing its captured properties (set
shared.existingDelegate = nil and shared.centerObservation = nil) or implement
and call a small reset() on KlaviyoNotificationDelegate that does this; keep the
existing klaviyoSwiftEnvironment = KlaviyoSwiftEnvironment.test() initialization
but ensure the singleton is cleared so tests cannot leak state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Enterprise

Run ID: e14baead-d7d1-4a52-83ba-0eb903b91665

📥 Commits

Reviewing files that changed from the base of the PR and between 4a84baa and 162e11f.

📒 Files selected for processing (5)
  • Sources/KlaviyoSwift/Klaviyo.swift
  • Sources/KlaviyoSwift/KlaviyoNotificationDelegate.swift
  • Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift
  • Tests/KlaviyoSwiftTests/KlaviyoNotificationDelegateTests.swift
  • Tests/KlaviyoSwiftTests/TestData.swift

Comment thread Sources/KlaviyoSwift/KlaviyoNotificationDelegate.swift
Comment thread Sources/KlaviyoSwift/KlaviyoNotificationDelegate.swift
@glenn-klaviyo glenn-klaviyo changed the title [iOS](AutoPushTracking): Injection + KVO wiring for KlaviyoNotificationDelegate feat(AutoPushTracking): Injection + KVO wiring for KlaviyoNotificationDelegate May 29, 2026
@glenn-klaviyo glenn-klaviyo marked this pull request as ready for review May 29, 2026 18:12
@glenn-klaviyo glenn-klaviyo requested review from a team as code owners May 29, 2026 18:12
@klaviyoit klaviyoit requested review from ashiu95 and ndurell May 29, 2026 18:12
@glenn-klaviyo glenn-klaviyo requested review from ab1470 and belleklaviyo and removed request for a team and ashiu95 May 29, 2026 18:12
@ajaysubra ajaysubra self-requested a review May 29, 2026 19:59
glenn-klaviyo and others added 2 commits May 29, 2026 16:42
…tialize()

- Add UserNotificationCenterProtocol to abstract UNUserNotificationCenter for testability
- Add injectIfEnabled() + inject(into:) with KVO observation on the delegate property
- Wire injectNotificationDelegate, isAutomaticPushTrackingEnabled, and notificationCenter
  into KlaviyoSwiftEnvironment so tests control all three without an app-bundle context
- Call klaviyoSwiftEnvironment.injectNotificationDelegate() from KlaviyoSDK.initialize(with:)
- Add KlaviyoNotificationDelegateTests covering wiring, plist gating, inject behavior,
  idempotency, and KVO re-injection via MockNotificationCenter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@glenn-klaviyo glenn-klaviyo force-pushed the gb/mage-657-auto-inject-delegate-kvo-plist branch from 0b4035b to 3ee11f6 Compare May 29, 2026 20:42
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Tests/KlaviyoSwiftTests/TestData.swift`:
- Line 234: TestData.swift fails to compile because MockNotificationCenter is
not visible in the test target; move or expose it into shared test support by
creating a common test helper (e.g., define SharedMockNotificationCenter in the
shared test helpers file) and update references in TestData.swift to use that
shared symbol (or unnest/relax access on the existing MockNotificationCenter so
it is public/internal to the test target). Ensure the new
SharedMockNotificationCenter (or the made-visible MockNotificationCenter) lives
in a file included by the test target and retains the same API used by
TestData.swift.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Enterprise

Run ID: 97f922e4-2258-426c-95ea-41f18b09c658

📥 Commits

Reviewing files that changed from the base of the PR and between 0b4035b and 3ee11f6.

📒 Files selected for processing (5)
  • Sources/KlaviyoSwift/Klaviyo.swift
  • Sources/KlaviyoSwift/KlaviyoNotificationDelegate.swift
  • Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift
  • Tests/KlaviyoSwiftTests/KlaviyoNotificationDelegateTests.swift
  • Tests/KlaviyoSwiftTests/TestData.swift
🚧 Files skipped from review as they are similar to previous changes (3)
  • Sources/KlaviyoSwift/KlaviyoSwiftEnvironment.swift
  • Tests/KlaviyoSwiftTests/KlaviyoNotificationDelegateTests.swift
  • Sources/KlaviyoSwift/KlaviyoNotificationDelegate.swift

Comment thread Tests/KlaviyoSwiftTests/TestData.swift
glenn-klaviyo and others added 2 commits May 29, 2026 17:02
… guard

TestData.swift references MockNotificationCenter unconditionally, so on
Xcode 15.4 where Swift Testing is unavailable the type was never compiled
and the release build failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…iOS 17 targets

MainActor.assumeIsolated is unavailable below iOS 17; fall back to
Task { @mainactor in } which is safe here because handler is @MainActor-bound
(implicitly Sendable) and the KVO closure does not capture the non-Sendable center.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

2 participants