Manos Chorianopoulos

Push Notifications implementation

...@@ -1249,35 +1249,128 @@ class LegacyViewController: UIViewController { ...@@ -1249,35 +1249,128 @@ class LegacyViewController: UIViewController {
1249 1249
1250 ## 📱 Push Notifications 1250 ## 📱 Push Notifications
1251 1251
1252 -### Setup 1252 +### Complete AppDelegate Setup
1253 +
1254 +The framework provides full push notification support. Add these methods to your `AppDelegate.swift`:
1253 1255
1254 ```swift 1256 ```swift
1255 -// In AppDelegate 1257 +import SwiftWarplyFramework
1256 -func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 1258 +import UserNotifications
1259 +
1260 +@main
1261 +class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
1262 +
1263 + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
1264 +
1265 + // 1️⃣ Configure and initialize SDK (as shown in Setup section above)
1266 + WarplySDK.shared.configure(appUuid: "YOUR_APP_UUID", merchantId: "YOUR_MERCHANT_ID")
1267 + WarplySDK.shared.initialize { success in
1268 + print("SDK Ready: \(success)")
1269 + }
1270 +
1271 + // 2️⃣ Check if app was launched from a push notification
1272 + WarplySDK.shared.handleLaunchOptions(launchOptions)
1273 +
1274 + // 3️⃣ Register for push notifications
1275 + UNUserNotificationCenter.current().delegate = self
1276 + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, error in
1277 + if granted {
1278 + DispatchQueue.main.async {
1279 + UIApplication.shared.registerForRemoteNotifications()
1280 + }
1281 + }
1282 + }
1283 +
1284 + return true
1285 + }
1286 +
1287 + // 4️⃣ Send device token to Warply server
1288 + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
1257 let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() 1289 let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
1258 WarplySDK.shared.updateDeviceToken(tokenString) 1290 WarplySDK.shared.updateDeviceToken(tokenString)
1259 -} 1291 + }
1292 +
1293 + // 5️⃣ Forward foreground notifications to SDK
1294 + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
1295 + let userInfo = notification.request.content.userInfo as? [String: Any] ?? [:]
1260 1296
1261 -func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) {
1262 - // Check if it's a Warply notification
1263 if WarplySDK.shared.checkForLoyaltySDKNotification(userInfo) { 1297 if WarplySDK.shared.checkForLoyaltySDKNotification(userInfo) {
1264 - print("Warply notification handled") 1298 + // Warply handles it (shows alert in foreground)
1299 + completionHandler([])
1265 } else { 1300 } else {
1266 - // Handle other notifications 1301 + // Not a Warply notification — show normally
1302 + completionHandler([.banner, .sound, .badge])
1303 + }
1304 + }
1305 +
1306 + // 6️⃣ Forward notification taps to SDK
1307 + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
1308 + let userInfo = response.notification.request.content.userInfo as? [String: Any] ?? [:]
1309 +
1310 + // Let the SDK handle Warply notifications (opens campaign WebView)
1311 + _ = WarplySDK.shared.checkForLoyaltySDKNotification(userInfo)
1312 +
1313 + completionHandler()
1267 } 1314 }
1268 } 1315 }
1269 ``` 1316 ```
1270 1317
1271 -### Handle Notifications 1318 +### Handle Pending Notifications
1319 +
1320 +When the app is launched from a push notification (cold start), call this in your root view controller:
1321 +
1322 +```swift
1323 +class MainViewController: UIViewController {
1324 + override func viewDidAppear(_ animated: Bool) {
1325 + super.viewDidAppear(animated)
1326 +
1327 + // Handle any pending push notification from cold start
1328 + WarplySDK.shared.handlePendingNotification()
1329 + }
1330 +}
1331 +```
1332 +
1333 +### Optional — Clear Badge on App Open
1334 +
1335 +The **badge** is the small red number circle on your app icon (e.g., showing "3" for 3 unread notifications). Call `resetBadge()` to clear it when the user opens the app:
1336 +
1337 +```swift
1338 +// In AppDelegate — clear the badge when user opens the app
1339 +func applicationDidBecomeActive(_ application: UIApplication) {
1340 + WarplySDK.shared.resetBadge()
1341 +}
1342 +```
1343 +
1344 +### Optional — Listen for Push Events
1345 +
1346 +If you want to react to push notifications in your own code (in addition to the SDK's automatic handling):
1272 1347
1273 ```swift 1348 ```swift
1274 -// Manual notification handling 1349 +// Modern EventDispatcher approach
1275 -WarplySDK.shared.handleNotification(notificationPayload) 1350 +let subscription = WarplySDK.shared.subscribe(PushNotificationReceivedEvent.self) { event in
1351 + print("Push received! Session: \(event.sessionUuid ?? "unknown")")
1352 +}
1353 +```
1354 +
1355 +### Advanced — Custom Push Handler (Most apps don't need this)
1276 1356
1277 -// Check if notification belongs to Warply SDK 1357 +By default, the SDK handles all Warply push notifications automatically (shows a campaign WebView). However, if the Warply backend sends pushes with a **custom action code** (`action != 0`), you can implement a handler to process them:
1278 -let isWarplyNotification = WarplySDK.shared.checkForLoyaltySDKNotification(payload) 1358 +
1359 +```swift
1360 +class MyPushHandler: WarplyPushHandler {
1361 + func didReceiveWarplyNotification(_ payload: WarplyNotificationPayload, whileAppWasIn state: WarplyApplicationState) {
1362 + // This is only called for pushes with action != 0
1363 + // Standard offer pushes (action == 0) are handled automatically by the SDK
1364 + print("Custom action push: \(payload.action)")
1365 + }
1366 +}
1367 +
1368 +// Set the delegate
1369 +WarplySDK.shared.pushHandlerDelegate = myPushHandler
1279 ``` 1370 ```
1280 1371
1372 +> **Note**: You only need this if the Warply/DEI backend team has told you they send custom action pushes. For standard offer notifications, the SDK handles everything automatically.
1373 +
1281 --- 1374 ---
1282 1375
1283 ## ⚠️ Error Handling 1376 ## ⚠️ Error Handling
......
1 +# Push Notifications Migration Plan: ObjC → Swift
2 +
3 +## 📋 Overview
4 +
5 +**Date**: July 3, 2025
6 +**Scope**: Migrate push notification functionality from old ObjC `WLPushManager` to the Swift `WarplySDK`
7 +**Source**: `/Users/manoschorianopoulos/Desktop/warply_projects/cosmote_sdk/warply_sdk_framework/WarplySDKFrameworkIOS/Warply/managers/WLPushManager.h/.m`
8 +**Target**: `SwiftWarplyFramework/SwiftWarplyFramework/Core/WarplySDK.swift` and related files
9 +**Priority**: 🔴 Critical — Push notifications are currently non-functional (stub implementations only)
10 +
11 +---
12 +
13 +## 🔍 Current State Analysis
14 +
15 +### Old ObjC Implementation (WLPushManager) — What Worked
16 +
17 +| Feature | Implementation | Status |
18 +|---------|---------------|--------|
19 +| Device token conversion (NSData → hex string) | `didRegisterForRemoteNotificationsWithDeviceToken:` | ✅ Full |
20 +| Device token persistence | Stored in `WLKeychain` | ✅ Full |
21 +| Device token → server | Via `sendDeviceInfo` event system | ✅ Full |
22 +| Warply notification detection | Check for `"_a"` key in payload | ✅ Full |
23 +| Notification routing by app state | Active/Background/Closed handling | ✅ Full |
24 +| Custom push handler protocol | `WLCustomPushHandler` protocol | ✅ Full |
25 +| Push analytics | `NB_PushAck` (engaged) + `NB_PushReceived` events | ✅ Full |
26 +| UNUserNotificationCenter delegate | Foreground + response handling | ✅ Full |
27 +| Pending notification queue | Store for later handling when app was closed | ✅ Full |
28 +| Badge reset | `resetBadge` method | ✅ Full |
29 +| Comprehensive device info | Carrier, IDFV, jailbreak, screen, etc. | ✅ Full |
30 +| Notification action categories | share, favorite, book categories | ✅ Full |
31 +
32 +### Current Swift Implementation — What's Broken
33 +
34 +| Feature | Current State | Problem |
35 +|---------|--------------|---------|
36 +| `updateDeviceToken()` | Saves to UserDefaults only | ❌ **Never sends token to server** |
37 +| `checkForLoyaltySDKNotification()` | Checks for `"loyalty_sdk"` / `"warply"` keys | ❌ **Wrong keys** — should check for `"_a"` |
38 +| `handleNotification()` | Just `print()` | ❌ **Does nothing** |
39 +| Push analytics | None | ❌ **Missing entirely** |
40 +| Custom push handler | None | ❌ **Missing entirely** |
41 +| Device info sending | Only during registration | ⚠️ **Partial** — not sent on token update |
42 +| Pending notification handling | None | ❌ **Missing entirely** |
43 +| Badge management | None | ❌ **Missing entirely** |
44 +
45 +---
46 +
47 +## 🏗️ Architecture Decision
48 +
49 +### Design Philosophy — Option A: Host App Forwards (Modern Pattern)
50 +
51 +The **modern Swift framework should NOT**:
52 +- ❌ Register for push notifications itself (host app's responsibility)
53 +- ❌ Set itself as `UNUserNotificationCenterDelegate` (conflicts with host app's own delegate, Firebase, etc.)
54 +- ❌ Call `UIApplication.shared.registerForRemoteNotifications()` (host app controls this)
55 +
56 +The **modern Swift framework SHOULD**:
57 +- ✅ Accept device tokens from the host app and send them to the Warply server
58 +- ✅ Detect Warply push notifications from payload
59 +- ✅ Process and route Warply notifications
60 +-**Present rich push content via `CampaignViewController` (WebView)** — consistent with old `WLPushManager.showItem:`
61 +- ✅ Provide convenience wrappers for `UNUserNotificationCenterDelegate` methods (host app just forwards)
62 +- ✅ Provide a delegate/protocol for custom push handling
63 +- ✅ Send push analytics events
64 +- ✅ Send comprehensive device info to server
65 +- ✅ Handle pending notifications
66 +- ✅ Provide badge management utilities
67 +
68 +This is the modern iOS best practice: the framework is a **helper**, not the **owner** of push notification registration.
69 +
70 +### Why Option A (Host App Forwards) Over Option B (SDK is Delegate)
71 +
72 +The old ObjC `WLPushManager` set itself as `UNUserNotificationCenter.delegate`. This created conflicts when the host app also had its own delegate (Firebase, OneSignal, etc.) — only one delegate can exist at a time.
73 +
74 +**Option A approach**: The host app remains the delegate and forwards calls to the SDK:
75 +```swift
76 +// Host app's AppDelegate — just forward these 2 calls to the SDK:
77 +func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
78 + WarplySDK.shared.willPresentNotification(notification, completionHandler: completionHandler)
79 +}
80 +
81 +func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
82 + WarplySDK.shared.didReceiveNotificationResponse(response, completionHandler: completionHandler)
83 +}
84 +```
85 +
86 +**Benefits:**
87 +- ✅ No delegate conflicts with Firebase, OneSignal, etc.
88 +- ✅ Host app has full control over notification behavior
89 +- ✅ Only requires 2-3 lines of forwarding code
90 +- ✅ End-user experience is 100% identical to old implementation
91 +- ✅ Every modern SDK (Firebase, OneSignal, Airship) uses this pattern
92 +
93 +---
94 +
95 +## 📝 Migration Steps
96 +
97 +### Phase 1: Core Infrastructure ✅
98 +
99 +- [x] **Step 1.1**: Create `WarplyNotificationPayload` model
100 + - Swift struct to parse Warply push notification payloads
101 + - Key fields: `action` (from `"_a"`), `sessionUuid` (from `"session_uuid"`), `message`, `customData`
102 + - Parse `aps` dictionary for alert, badge, sound, category
103 + - Equivalent of old `WLBaseItem.initWithAttributes:`
104 + - **File**: New section in `Models/` or within `WarplySDK.swift`
105 +
106 +- [x] **Step 1.2**: Create `WarplyPushHandler` protocol
107 + - Swift equivalent of `WLCustomPushHandler`
108 + - Method: `func didReceiveWarplyNotification(_ payload: WarplyNotificationPayload, whileAppWasIn state: WarplyApplicationState)`
109 + - Enum: `WarplyApplicationState { case active, background, closed }`
110 + - **File**: New protocol definition in `WarplySDK.swift` or dedicated file
111 +
112 +### Phase 2: Device Token Management ✅
113 +
114 +- [x] **Step 2.1**: Fix `updateDeviceToken()` in `WarplySDK.swift`
115 + - Store token in UserDefaults (keep existing behavior)
116 + - **Also call `NetworkService.updateDeviceToken()`** to send to server
117 + - Add Dynatrace analytics event for success/failure
118 + - Log properly with emoji convention
119 +
120 +- [x] **Step 2.2**: Enhance `Endpoint.sendDeviceInfo` to send comprehensive device info
121 + - Currently only sends `{"device": {"device_token": token}}`
122 + - Add all device info fields from old `WLPushManager.deviceInfo`:
123 + - `device_token`, `platform` ("ios"), `vendor` ("apple"), `os_version`
124 + - `unique_device_id` (IDFV), `ios_system_name`, `ios_system_version`
125 + - `ios_model`, `ios_locale`, `ios_languages`
126 + - `screen_resolution`, `bundle_identifier`, `app_version`, `app_build`
127 + - `development` flag (DEBUG vs release)
128 + - **File**: `Network/Endpoints.swift` — update `sendDeviceInfo` case parameters and body
129 +
130 +- [x] **Step 2.3**: Add `sendDeviceInfo()` method to `WarplySDK.swift`
131 + - Public method that collects all device info and sends to server
132 + - Called automatically after `updateDeviceToken()`
133 + - Can also be called manually by host app
134 + - Tracks whether device info has been sent this session (like old `hasSentDeviceInfo`)
135 + - Only sends if device info has changed (compare with last sent, stored in UserDefaults)
136 +
137 +### Phase 3: Notification Detection & Handling ✅
138 +
139 +- [x] **Step 3.1**: Fix `checkForLoyaltySDKNotification()` in `WarplySDK.swift`
140 + - Change detection from `"loyalty_sdk"` / `"warply"` keys to `"_a"` key (the actual Warply identifier)
141 + - Parse payload into `WarplyNotificationPayload`
142 + - Return `true` if `"_a"` key exists in payload
143 + - Call `handleNotification()` if it's a Warply notification
144 +
145 +- [x] **Step 3.2**: Implement `handleNotification()` in `WarplySDK.swift`
146 + - Parse payload into `WarplyNotificationPayload`
147 + - Determine app state (active/background/closed)
148 + - Route based on `action` field:
149 + - `action == 0`: Default Warply handling (post event for host app to show campaign)
150 + - `action != 0`: Call custom push handler delegate if set
151 + - Post `SwiftEventBus` + `EventDispatcher` events for the notification
152 + - Post Dynatrace analytics event
153 +
154 +- [x] **Step 3.3**: Add push analytics events
155 + - `logPushReceived(sessionUuid:)` — sends `NB_PushReceived` event (equivalent of old `logUserReceivedPush:`)
156 + - `logPushEngaged(sessionUuid:)` — sends `NB_PushAck` event (equivalent of old `logUserEngagedPush:`)
157 + - Use existing `Endpoint.sendEvent` for analytics
158 + - Follows existing Dynatrace event pattern
159 +
160 +- [x] **Step 3.4**: Add pending notification support
161 + - Store pending notification when app was closed (received via `didFinishLaunchingWithOptions`)
162 + - `handlePendingNotification()` — processes stored notification, returns `Bool`
163 + - Host app calls this from their root view controller's `viewDidAppear`
164 +
165 +- [x] **Step 3.5**: Rich push presentation via `CampaignViewController` (WebView)
166 + - For `action == 0` pushes (default Warply handling), the **framework handles UI presentation internally**:
167 + - Build campaign URL from `session_uuid`: `{baseURL}/api/session/{session_uuid}`
168 + - Build params JSON via `constructCampaignParams()`
169 + - Create `CampaignViewController`, set `campaignUrl` and `params`
170 + - Present modally (wrapped in `UINavigationController`) on the topmost view controller
171 + - Behavior by app state (consistent with old `WLPushManager.showItem:`):
172 + - **Active** state: Show an alert first ("You have a new offer"), present WebView on user confirmation
173 + - **Background** state: Present WebView immediately when app returns to foreground
174 + - **Closed** state: Store as pending, show when `handlePendingNotification()` is called
175 + - Add helper: `showPushCampaign(sessionUuid:)` — public method host apps can call directly
176 + - Add helper: `getTopViewController()` — finds topmost presented VC for modal presentation
177 + - **Reuses existing `CampaignViewController`** — no new screen needed
178 + - **File**: `Core/WarplySDK.swift`
179 +
180 +### Phase 4: Public API & Delegate ✅
181 +
182 +- [x] **Step 4.1**: Add `pushHandlerDelegate` property to `WarplySDK`
183 + - `weak var pushHandlerDelegate: WarplyPushHandler?`
184 + - Host app sets this to receive custom push notifications (action != 0)
185 + - Modern Swift alternative to old `WLCustomPushHandler` protocol
186 +
187 +- [x] **Step 4.2**: Add convenience methods to `WarplySDK`
188 + - `resetBadge()` — resets app icon badge to 0
189 + - `handleLaunchOptions(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]?)` — checks for push in launch options
190 + - `didReceiveRemoteNotification(_ userInfo: [AnyHashable: Any], appState: WarplyApplicationState)` — main entry point for push handling
191 + - **UNUserNotificationCenter convenience wrappers** (host app forwards these):
192 + - `willPresentNotification(_ notification: UNNotification, completionHandler: ...)` — handles foreground push display
193 + - If Warply push: calls `handleNotification()` with `.active` state, shows alert
194 + - If not Warply push: calls completionHandler with `.banner, .sound, .badge` (pass-through)
195 + - `didReceiveNotificationResponse(_ response: UNNotificationResponse, completionHandler: ...)` — handles user tap on notification
196 + - Determines app state, calls `handleNotification()`, logs analytics (`NB_PushAck`)
197 + - Calls completionHandler after processing
198 +
199 +- [x] **Step 4.3**: Add new push-related events to `EventDispatcher`
200 + - `PushNotificationReceivedEvent` — posted when Warply push is received
201 + - `PushNotificationEngagedEvent` — posted when user interacts with push
202 + - Both SwiftEventBus and EventDispatcher (dual posting pattern)
203 + - Event names: `"push_notification_received"`, `"push_notification_engaged"`
204 +
205 +### Phase 5: Documentation & Client Integration ✅
206 +
207 +- [x] **Step 5.1**: Update `CLIENT_DOCUMENTATION.md` Push Notifications section
208 + - Replace current minimal docs with comprehensive guide
209 + - Include all 3 AppDelegate methods needed:
210 + 1. `didFinishLaunchingWithOptions` — handle launch from push
211 + 2. `didRegisterForRemoteNotificationsWithDeviceToken` — send token to SDK
212 + 3. `didReceiveRemoteNotification` — route notification through SDK
213 + - Show custom push handler setup
214 + - Show event subscription for push notifications
215 +
216 +- [x] **Step 5.2**: Update `skill.md` with push notification architecture
217 + - Document new `WarplyNotificationPayload` model
218 + - Document `WarplyPushHandler` protocol
219 + - Document push analytics events
220 + - Update API reference section
221 +
222 +- [x] **Step 5.3**: Update `QUICK_REFERENCE.md` push section
223 + - Updated code examples matching the new implementation
224 + - Quick copy-paste setup guide
225 +
226 +---
227 +
228 +## 📄 Files to Modify
229 +
230 +| File | Changes | Impact |
231 +|------|---------|--------|
232 +| `Core/WarplySDK.swift` | Replace stub push methods with full implementation, add delegate, add device info methods | 🔴 High |
233 +| `Network/Endpoints.swift` | Enhance `sendDeviceInfo` endpoint with full device info payload | 🟡 Medium |
234 +| `Network/NetworkService.swift` | Enhance `updateDeviceToken` to send full device info | 🟡 Medium |
235 +| `Events/EventDispatcher.swift` | Add `PushNotificationReceivedEvent` and `PushNotificationEngagedEvent` types | 🟢 Low |
236 +| `CLIENT_DOCUMENTATION.md` | Complete rewrite of push notifications section | 🟡 Medium |
237 +| `skill.md` | Add push notification architecture documentation | 🟢 Low |
238 +| `QUICK_REFERENCE.md` | Update push notification code examples | 🟢 Low |
239 +
240 +---
241 +
242 +## 🗺️ ObjC → Swift Method Mapping
243 +
244 +| Old ObjC (WLPushManager) | New Swift (WarplySDK) | Notes |
245 +|---------------------------|----------------------|-------|
246 +| `didRegisterForRemoteNotificationsWithDeviceToken:` | `updateDeviceToken(_:)` | Host app converts Data→String, calls this |
247 +| `didFailToRegisterForRemoteNotificationsWithError:` | *(not needed)* | Host app handles the failure |
248 +| `registerForRemoteNotifications` | *(not needed)* | Host app registers directly |
249 +| `didFinishLaunchingWithOptions:` | `handleLaunchOptions(_:)` | Check for push in launch options |
250 +| `didReceiveRemoteNotification:whileAppWasInState:` | `didReceiveRemoteNotification(_:appState:)` | Main notification entry point |
251 +| `application:didReceiveRemoteNotification:` | `checkForLoyaltySDKNotification(_:)` | Detection + handling |
252 +| `handlePendingNotification` | `handlePendingNotification()` | Process queued notification |
253 +| `showItem:` | `showPushCampaign(sessionUuid:)` | Presents `CampaignViewController` modally with campaign URL |
254 +| `resetBadge` | `resetBadge()` | Direct port |
255 +| `sendDeviceInfoIfNecessary` | `sendDeviceInfoIfNeeded()` | Smart send with change detection |
256 +| `sendDeviceInfo` | `sendDeviceInfo()` | Force send device info |
257 +| `deviceInfo``NSDictionary` | `buildDeviceInfo()``[String: Any]` | Comprehensive device info dict |
258 +| `applicationData``NSDictionary` | *(merged into buildDeviceInfo)* | Combined into single method |
259 +| `WLCustomPushHandler` protocol | `WarplyPushHandler` protocol | Swift protocol |
260 +| `WLBaseItem.initWithAttributes:` | `WarplyNotificationPayload(userInfo:)` | Swift struct with init |
261 +| `WLAnalyticsManager.logUserEngagedPush:` | `logPushEngaged(sessionUuid:)` | Sends `NB_PushAck` |
262 +| `WLAnalyticsManager.logUserReceivedPush:` | `logPushReceived(sessionUuid:)` | Sends `NB_PushReceived` |
263 +| `willPresentNotification:` (UNDelegate) | `willPresentNotification(_:completionHandler:)` | Host app forwards; SDK handles Warply pushes |
264 +| `didReceiveNotificationResponse:` (UNDelegate) | `didReceiveNotificationResponse(_:completionHandler:)` | Host app forwards; SDK routes + logs analytics |
265 +
266 +---
267 +
268 +## 🧪 Testing Checklist
269 +
270 +After implementation, verify:
271 +
272 +- [ ] Device token is stored in UserDefaults when `updateDeviceToken()` called
273 +- [ ] Device token is sent to server via `sendDeviceInfo` endpoint
274 +- [ ] `checkForLoyaltySDKNotification()` returns `true` for payloads with `"_a"` key
275 +- [ ] `checkForLoyaltySDKNotification()` returns `false` for non-Warply payloads
276 +- [ ] `handleNotification()` posts events (both SwiftEventBus + EventDispatcher)
277 +- [ ] `handleNotification()` calls custom push handler delegate for action != 0
278 +- [ ] Push analytics events (`NB_PushAck`, `NB_PushReceived`) are sent
279 +- [ ] `handleLaunchOptions()` detects push payload in launch options
280 +- [ ] `handlePendingNotification()` processes stored notification
281 +- [ ] `resetBadge()` sets badge to 0
282 +- [ ] `sendDeviceInfo()` sends comprehensive device info to server
283 +- [ ] Device info is only sent when it has changed (not on every launch)
284 +- [ ] Dynatrace events are posted for push operations (success + failure)
285 +- [ ] Rich push: `showPushCampaign(sessionUuid:)` presents `CampaignViewController` modally
286 +- [ ] Rich push: Campaign URL correctly built as `{baseURL}/api/session/{session_uuid}`
287 +- [ ] Rich push: Alert shown when push received in active (foreground) state
288 +- [ ] Rich push: WebView shown immediately when push received in background state
289 +- [ ] Rich push: Pending notification stored and shown via `handlePendingNotification()` for closed state
290 +- [ ] `willPresentNotification()` correctly handles Warply vs non-Warply pushes
291 +- [ ] `didReceiveNotificationResponse()` routes notification and logs `NB_PushAck`
292 +- [ ] No compilation errors introduced
293 +- [ ] All existing public API remains backward compatible
294 +
295 +---
296 +
297 +## ⚠️ Important Notes
298 +
299 +1. **`"_a"` is the Warply Push Identifier** — The old ObjC code uses `[userInfo valueForKey:@"_a"]` to detect Warply pushes. The current Swift stub incorrectly checks for `"loyalty_sdk"` and `"warply"` keys.
300 +
301 +2. **The framework should NOT be UNUserNotificationCenterDelegate** — In the old ObjC code, WLPushManager set itself as `UNUserNotificationCenter.delegate`. In modern iOS, the host app owns this. The framework should only provide helper methods.
302 +
303 +3. **Device info sending uses the event system** — In old ObjC, device info was sent via `WLEventSimple` (type: `"device_info"`) through `addEvent:priority:`. In Swift, we use the existing `Endpoint.sendDeviceInfo` which POSTs to `/api/async/info/{appUUID}/`.
304 +
305 +4. **Rich push WebView handled by framework** — The old `showItem:` presented a `WLInboxItemViewController` WebView for rich pushes. The modern framework maintains this behavior by reusing the existing `CampaignViewController` — for `action == 0` pushes, the framework builds a campaign URL from `session_uuid` (`{baseURL}/api/session/{session_uuid}`) and presents `CampaignViewController` modally. This ensures full consistency with the old implementation.
306 +
307 +5. **Keychain vs UserDefaults for device token** — Old code used `WLKeychain`. New code uses `UserDefaults`. Device tokens are not sensitive (they're sent to servers in plain text), so `UserDefaults` is acceptable. However, we could optionally use `KeychainManager` for consistency.
308 +
309 +---
310 +
311 +## 📊 Estimated Effort
312 +
313 +| Phase | Estimated Time | Complexity |
314 +|-------|---------------|------------|
315 +| Phase 1: Core Infrastructure | 30 min | Medium |
316 +| Phase 2: Device Token Management | 45 min | Medium |
317 +| Phase 3: Notification Detection & Handling | 45 min | Medium-High |
318 +| Phase 4: Public API & Delegate | 30 min | Low-Medium |
319 +| Phase 5: Documentation | 30 min | Low |
320 +| **Total** | **~3 hours** | **Medium** |
321 +
322 +---
323 +
324 +*Plan created: July 3, 2025*
325 +*Author: AI Migration Assistant*
...@@ -177,18 +177,46 @@ Task { ...@@ -177,18 +177,46 @@ Task {
177 177
178 ## 📱 Push Notifications 178 ## 📱 Push Notifications
179 179
180 +### Required Setup (AppDelegate)
181 +
180 ```swift 182 ```swift
181 -// AppDelegate 183 +// 1️⃣ In didFinishLaunchingWithOptions — check if app launched from push
184 +WarplySDK.shared.handleLaunchOptions(launchOptions)
185 +
186 +// 2️⃣ Send device token to server
182 func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 187 func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
183 let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() 188 let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
184 WarplySDK.shared.updateDeviceToken(tokenString) 189 WarplySDK.shared.updateDeviceToken(tokenString)
185 } 190 }
186 191
187 -func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) { 192 +// 3️⃣ Forward foreground notifications (UNUserNotificationCenterDelegate)
193 +func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, ...) {
194 + let userInfo = notification.request.content.userInfo as? [String: Any] ?? [:]
188 if WarplySDK.shared.checkForLoyaltySDKNotification(userInfo) { 195 if WarplySDK.shared.checkForLoyaltySDKNotification(userInfo) {
189 - // Warply notification handled 196 + completionHandler([]) // SDK handles it (shows alert)
197 + } else {
198 + completionHandler([.banner, .sound, .badge]) // Not a Warply push
190 } 199 }
191 } 200 }
201 +
202 +// 4️⃣ Forward notification taps (UNUserNotificationCenterDelegate)
203 +func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, ...) {
204 + let userInfo = response.notification.request.content.userInfo as? [String: Any] ?? [:]
205 + _ = WarplySDK.shared.checkForLoyaltySDKNotification(userInfo)
206 + completionHandler()
207 +}
208 +
209 +// 5️⃣ Handle pending notifications (in root ViewController's viewDidAppear)
210 +WarplySDK.shared.handlePendingNotification()
211 +```
212 +
213 +### Optional — Clear Badge on App Open
214 +
215 +```swift
216 +// In applicationDidBecomeActive — clears the red number on the app icon
217 +func applicationDidBecomeActive(_ application: UIApplication) {
218 + WarplySDK.shared.resetBadge()
219 +}
192 ``` 220 ```
193 221
194 --- 222 ---
......
...@@ -50,6 +50,213 @@ public enum WarplyError: Error { ...@@ -50,6 +50,213 @@ public enum WarplyError: Error {
50 } 50 }
51 } 51 }
52 52
53 +// MARK: - Push Notification Types
54 +
55 +/// Application state when a push notification is received
56 +/// Equivalent of old ObjC `WLApplicationState` enum
57 +public enum WarplyApplicationState: Int {
58 + /// The application is running in the foreground and currently receiving events
59 + case active = 0
60 + /// The application is running in the background
61 + case background = 1
62 + /// The application was not running (cold start from push)
63 + case closed = 2
64 +}
65 +
66 +/// Protocol for custom push notification handling
67 +/// Equivalent of old ObjC `WLCustomPushHandler` protocol
68 +///
69 +/// Implement this protocol and set `WarplySDK.shared.pushHandlerDelegate` to receive
70 +/// push notifications with `action != 0` (non-default Warply pushes).
71 +///
72 +/// For `action == 0` pushes, the framework handles them automatically by presenting
73 +/// a `CampaignViewController` WebView with the campaign content.
74 +public protocol WarplyPushHandler: AnyObject {
75 + /// Called when a Warply push notification is received with a custom action (action != 0)
76 + /// - Parameters:
77 + /// - payload: The parsed notification payload
78 + /// - state: The application state when the notification was received
79 + func didReceiveWarplyNotification(_ payload: WarplyNotificationPayload, whileAppWasIn state: WarplyApplicationState)
80 +}
81 +
82 +/// APS (Apple Push Notification Service) item parsed from push payload
83 +/// Equivalent of old ObjC `WLAPSItem`
84 +public struct WarplyAPSItem {
85 + /// The alert text or dictionary from the push notification
86 + public let alert: String?
87 + /// The alert title (if alert is a dictionary)
88 + public let alertTitle: String?
89 + /// The alert body (if alert is a dictionary)
90 + public let alertBody: String?
91 + /// The badge number to display on the app icon
92 + public let badge: Int?
93 + /// The sound to play when the notification is received
94 + public let sound: String?
95 + /// The notification category for action buttons
96 + public let category: String?
97 + /// The content-available flag for silent pushes
98 + public let contentAvailable: Bool
99 + /// The mutable-content flag for notification service extensions
100 + public let mutableContent: Bool
101 +
102 + /// Initialize from the "aps" dictionary in a push payload
103 + public init(dictionary: [String: Any]?) {
104 + guard let dict = dictionary else {
105 + self.alert = nil
106 + self.alertTitle = nil
107 + self.alertBody = nil
108 + self.badge = nil
109 + self.sound = nil
110 + self.category = nil
111 + self.contentAvailable = false
112 + self.mutableContent = false
113 + return
114 + }
115 +
116 + // Parse alert — can be a string or a dictionary
117 + if let alertString = dict["alert"] as? String {
118 + self.alert = alertString
119 + self.alertTitle = nil
120 + self.alertBody = alertString
121 + } else if let alertDict = dict["alert"] as? [String: Any] {
122 + self.alertTitle = alertDict["title"] as? String
123 + self.alertBody = alertDict["body"] as? String
124 + self.alert = self.alertBody ?? self.alertTitle
125 + } else {
126 + self.alert = nil
127 + self.alertTitle = nil
128 + self.alertBody = nil
129 + }
130 +
131 + self.badge = dict["badge"] as? Int
132 + self.sound = dict["sound"] as? String
133 + self.category = dict["category"] as? String
134 + self.contentAvailable = (dict["content-available"] as? Int) == 1
135 + self.mutableContent = (dict["mutable-content"] as? Int) == 1
136 + }
137 +}
138 +
139 +/// Parsed Warply push notification payload
140 +/// Equivalent of old ObjC `WLBaseItem` initialized via `initWithAttributes:`
141 +///
142 +/// The Warply push notification payload has this structure:
143 +/// ```json
144 +/// {
145 +/// "_a": 0, // action: 0 = default Warply handling, != 0 = custom
146 +/// "session_uuid": "abc-123", // unique campaign session identifier
147 +/// "aps": {
148 +/// "alert": "You have a new offer!",
149 +/// "badge": 1,
150 +/// "sound": "default",
151 +/// "category": "shareCategory"
152 +/// },
153 +/// ... // any additional custom data
154 +/// }
155 +/// ```
156 +public struct WarplyNotificationPayload {
157 + /// The action type for this notification
158 + /// - `0`: Default Warply handling (show rich push WebView via CampaignViewController)
159 + /// - `!= 0`: Custom action — forwarded to `WarplyPushHandler` delegate
160 + public let action: Int
161 +
162 + /// The unique session UUID for the campaign/offer associated with this push
163 + public let sessionUuid: String?
164 +
165 + /// The trace identifier for tracking
166 + public let trace: String?
167 +
168 + /// The parsed APS (Apple Push Service) data
169 + public let apsItem: WarplyAPSItem
170 +
171 + /// The alert message text from the push notification
172 + public var message: String? {
173 + return apsItem.alert
174 + }
175 +
176 + /// Any additional custom data from the push payload (keys not recognized by the parser)
177 + public let customData: [String: Any]
178 +
179 + /// The raw push notification payload dictionary
180 + public let rawPayload: [String: Any]
181 +
182 + /// Whether this is a Warply push notification (contains the "_a" key)
183 + public var isWarplyNotification: Bool {
184 + return rawPayload["_a"] != nil
185 + }
186 +
187 + /// The number of times the offer has been opened (if tracked)
188 + public var openedCount: Int = 0
189 +
190 + // MARK: - Initialization
191 +
192 + /// Initialize from a push notification userInfo dictionary
193 + /// Equivalent of old ObjC `WLBaseItem.initWithAttributes:`
194 + /// - Parameter userInfo: The push notification payload dictionary
195 + public init(userInfo: [AnyHashable: Any]) {
196 + // Store raw payload
197 + var rawDict: [String: Any] = [:]
198 + for (key, value) in userInfo {
199 + if let stringKey = key as? String {
200 + rawDict[stringKey] = value
201 + }
202 + }
203 + self.rawPayload = rawDict
204 +
205 + // Parse action from "_a" key (Warply's push identifier)
206 + if let actionValue = rawDict["_a"] {
207 + if let actionInt = actionValue as? Int {
208 + self.action = actionInt
209 + } else if let actionNumber = actionValue as? NSNumber {
210 + self.action = actionNumber.intValue
211 + } else if let actionString = actionValue as? String, let actionInt = Int(actionString) {
212 + self.action = actionInt
213 + } else {
214 + self.action = 0
215 + }
216 + } else {
217 + self.action = 0
218 + }
219 +
220 + // Parse session_uuid
221 + self.sessionUuid = rawDict["session_uuid"] as? String
222 +
223 + // Parse trace
224 + self.trace = rawDict["trace"] as? String
225 +
226 + // Parse APS dictionary
227 + self.apsItem = WarplyAPSItem(dictionary: rawDict["aps"] as? [String: Any])
228 +
229 + // Collect custom data (everything except known keys)
230 + let knownKeys: Set<String> = ["_a", "session_uuid", "trace", "aps"]
231 + var custom: [String: Any] = [:]
232 + for (key, value) in rawDict {
233 + if !knownKeys.contains(key) {
234 + custom[key] = value
235 + }
236 + }
237 + self.customData = custom
238 + }
239 +
240 + // MARK: - URL Construction
241 +
242 + /// Get the campaign page URL for this push notification
243 + /// Uses the session_uuid to construct the URL: `{baseURL}/api/session/{session_uuid}`
244 + /// - Returns: The campaign URL string, or nil if no session_uuid exists
245 + public func getPageURL() -> String? {
246 + guard let sessionUuid = sessionUuid, !sessionUuid.isEmpty else { return nil }
247 + let baseURL = Configuration.baseURL
248 + return "\(baseURL)/api/session/\(sessionUuid)"
249 + }
250 +
251 + /// Get the campaign image URL for this push notification
252 + /// - Returns: The image URL string, or nil if no session_uuid exists
253 + public func getImageURL() -> String? {
254 + guard let sessionUuid = sessionUuid, !sessionUuid.isEmpty else { return nil }
255 + let baseURL = Configuration.baseURL
256 + return "\(baseURL)/api/session/\(sessionUuid)/image"
257 + }
258 +}
259 +
53 // MARK: - Error Handling Utilities 260 // MARK: - Error Handling Utilities
54 261
55 extension WarplySDK { 262 extension WarplySDK {
...@@ -2995,39 +3202,416 @@ public final class WarplySDK { ...@@ -2995,39 +3202,416 @@ public final class WarplySDK {
2995 3202
2996 // MARK: - Push Notifications 3203 // MARK: - Push Notifications
2997 3204
2998 - /// Handle notification 3205 + /// Whether device info has been sent during this session
2999 - public func handleNotification(_ payload: [String: Any]) { 3206 + private var hasSentDeviceInfo: Bool = false
3000 - // Pure Swift notification handling - access Warply directly if needed 3207 +
3001 - // For now, we'll implement basic notification handling 3208 + /// Stored pending notification (received when app was closed)
3002 - print("Handling notification: \(payload)") 3209 + private var pendingNotificationPayload: WarplyNotificationPayload?
3210 +
3211 + /// Custom push handler delegate for action != 0 notifications
3212 + public weak var pushHandlerDelegate: WarplyPushHandler?
3213 +
3214 + /// Handle notification payload
3215 + /// Parses the payload and routes based on action type
3216 + /// - Parameters:
3217 + /// - payload: Raw push notification payload dictionary
3218 + /// - state: Application state when notification was received (defaults to .active)
3219 + public func handleNotification(_ payload: [String: Any], appState state: WarplyApplicationState = .active) {
3220 + let notificationPayload = WarplyNotificationPayload(userInfo: payload)
3003 3221
3004 - // TODO: Implement pure Swift notification handling 3222 + guard notificationPayload.isWarplyNotification else {
3005 - // This may require accessing Warply/ directory directly for infrastructure 3223 + print("ℹ️ [WarplySDK] Push notification is not from Warply (no '_a' key) — ignoring")
3224 + return
3006 } 3225 }
3007 3226
3008 - /// Check for loyalty SDK notification 3227 + print("📱 [WarplySDK] Handling Warply notification:")
3009 - public func checkForLoyaltySDKNotification(_ payload: [String: Any]) -> Bool { 3228 + print(" Action: \(notificationPayload.action)")
3010 - // Pure Swift notification checking - basic implementation 3229 + print(" Session UUID: \(notificationPayload.sessionUuid ?? "nil")")
3011 - // TODO: Implement proper notification checking logic 3230 + print(" Message: \(notificationPayload.message ?? "nil")")
3012 - print("Checking for loyalty SDK notification: \(payload)") 3231 + print(" App State: \(state)")
3232 +
3233 + // Log push received analytics
3234 + if let sessionUuid = notificationPayload.sessionUuid {
3235 + logPushReceived(sessionUuid: sessionUuid)
3236 + }
3237 +
3238 + // Post push notification received event (dual system)
3239 + postFrameworkEvent("push_notification_received", sender: notificationPayload.rawPayload)
3013 3240
3014 - // For now, assume it's a loyalty SDK notification if it contains certain keys 3241 + // Route based on action
3015 - if payload["loyalty_sdk"] != nil || payload["warply"] != nil { 3242 + if notificationPayload.action != 0 {
3243 + // Custom action — forward to delegate
3244 + print("📱 [WarplySDK] Custom push action (\(notificationPayload.action)) — forwarding to pushHandlerDelegate")
3245 + pushHandlerDelegate?.didReceiveWarplyNotification(notificationPayload, whileAppWasIn: state)
3246 + } else {
3247 + // Default Warply handling (action == 0) — show rich push via CampaignViewController
3248 + print("📱 [WarplySDK] Default Warply push (action == 0) — will present campaign WebView")
3249 +
3250 + switch state {
3251 + case .active:
3252 + // App is in foreground — show alert first, then present on confirmation
3253 + showPushAlertAndPresent(notificationPayload)
3254 + case .background:
3255 + // App was in background — present immediately
3256 + if let sessionUuid = notificationPayload.sessionUuid {
3257 + showPushCampaign(sessionUuid: sessionUuid)
3258 + }
3259 + case .closed:
3260 + // App was closed — store as pending for later handling
3261 + pendingNotificationPayload = notificationPayload
3262 + print("📱 [WarplySDK] Notification stored as pending (app was closed)")
3263 + }
3264 + }
3265 +
3266 + // Post Dynatrace analytics
3267 + let dynatraceEvent = LoyaltySDKDynatraceEventModel()
3268 + dynatraceEvent._eventName = "custom_success_push_notification_handled_loyalty"
3269 + dynatraceEvent._parameters = nil
3270 + postFrameworkEvent("dynatrace", sender: dynatraceEvent)
3271 + }
3272 +
3273 + /// Check if a push notification belongs to the Warply/Loyalty SDK
3274 + /// Uses the `"_a"` key which is the Warply push notification identifier
3275 + /// - Parameter payload: Raw push notification payload dictionary
3276 + /// - Returns: `true` if this is a Warply notification, `false` otherwise
3277 + public func checkForLoyaltySDKNotification(_ payload: [String: Any]) -> Bool {
3278 + // Check for "_a" key — the actual Warply push notification identifier
3279 + // (Old ObjC code: [userInfo valueForKey:@"_a"])
3280 + if payload["_a"] != nil {
3281 + print("✅ [WarplySDK] Warply push notification detected (contains '_a' key)")
3016 handleNotification(payload) 3282 handleNotification(payload)
3017 return true 3283 return true
3018 } 3284 }
3019 3285
3286 + print("ℹ️ [WarplySDK] Not a Warply push notification (no '_a' key)")
3020 return false 3287 return false
3021 } 3288 }
3022 3289
3023 - /// Update device token 3290 + /// Update device token and send comprehensive device info to server
3291 + /// Called by the host app from `didRegisterForRemoteNotificationsWithDeviceToken:`
3292 + /// - Parameter newDeviceToken: The APNS device token as a hex string
3024 public func updateDeviceToken(_ newDeviceToken: String) { 3293 public func updateDeviceToken(_ newDeviceToken: String) {
3025 - // Pure Swift device token handling - basic implementation 3294 + print("📱 [WarplySDK] Updating device token: \(newDeviceToken.prefix(16))...")
3026 - // TODO: Implement proper device token handling
3027 - print("Updating device token: \(newDeviceToken)")
3028 3295
3029 - // Store device token for future use 3296 + // Store device token locally
3030 UserDefaults.standard.set(newDeviceToken, forKey: "device_token") 3297 UserDefaults.standard.set(newDeviceToken, forKey: "device_token")
3298 +
3299 + // Send comprehensive device info to server (includes the token)
3300 + sendDeviceInfoIfNeeded()
3301 +
3302 + // Post Dynatrace analytics
3303 + let dynatraceEvent = LoyaltySDKDynatraceEventModel()
3304 + dynatraceEvent._eventName = "custom_success_device_token_updated_loyalty"
3305 + dynatraceEvent._parameters = nil
3306 + postFrameworkEvent("dynatrace", sender: dynatraceEvent)
3307 + }
3308 +
3309 + // MARK: - Device Info Management
3310 +
3311 + /// Build comprehensive device info dictionary
3312 + /// Equivalent of old ObjC `WLPushManager.deviceInfo` + `WLPushManager.applicationData`
3313 + /// - Returns: Dictionary with all device and application information
3314 + public func buildDeviceInfo() -> [String: Any] {
3315 + var deviceInfo: [String: Any] = [:]
3316 +
3317 + // Device token
3318 + let deviceToken = UserDefaults.standard.string(forKey: "device_token") ?? ""
3319 + if !deviceToken.isEmpty {
3320 + deviceInfo["device_token"] = deviceToken
3321 + }
3322 +
3323 + // Platform identification
3324 + deviceInfo["vendor"] = "apple"
3325 + deviceInfo["platform"] = "ios"
3326 + deviceInfo["os_version"] = UIDevice.current.systemVersion
3327 +
3328 + // Device identification
3329 + deviceInfo["unique_device_id"] = UIDevice.current.identifierForVendor?.uuidString ?? ""
3330 + deviceInfo["ios_system_name"] = UIDevice.current.systemName
3331 + deviceInfo["ios_system_version"] = UIDevice.current.systemVersion
3332 + deviceInfo["ios_model"] = UIDevice.current.modelName
3333 + deviceInfo["ios_localized_model"] = UIDevice.current.localizedModel
3334 +
3335 + // Locale & Language
3336 + deviceInfo["ios_locale"] = Locale.current.identifier
3337 + let prefLangs = Locale.preferredLanguages
3338 + let langsString = prefLangs.prefix(5).joined(separator: ", ")
3339 + if !langsString.isEmpty {
3340 + deviceInfo["ios_languages"] = langsString
3341 + }
3342 +
3343 + // Screen resolution
3344 + let screenWidth = UIScreen.main.bounds.width * UIScreen.main.scale
3345 + let screenHeight = UIScreen.main.bounds.height * UIScreen.main.scale
3346 + deviceInfo["screen_resolution"] = String(format: "%.0fx%.0f", screenWidth, screenHeight)
3347 +
3348 + // Identifier for vendor (IDFV)
3349 + deviceInfo["identifier_for_vendor"] = UIDevice.current.identifierForVendor?.uuidString ?? ""
3350 +
3351 + // Development flag
3352 + #if DEBUG
3353 + deviceInfo["development"] = "true"
3354 + #else
3355 + deviceInfo["development"] = "false"
3356 + #endif
3357 +
3358 + // Application data (merged — was separate `applicationData` in old ObjC)
3359 + let mainBundle = Bundle.main
3360 +
3361 + if let bundleId = mainBundle.bundleIdentifier, !bundleId.isEmpty {
3362 + deviceInfo["bundle_identifier"] = bundleId
3363 + }
3364 +
3365 + if let appVersion = mainBundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, !appVersion.isEmpty {
3366 + deviceInfo["app_version"] = appVersion
3367 + }
3368 +
3369 + if let buildVersion = mainBundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String, !buildVersion.isEmpty {
3370 + deviceInfo["app_build"] = buildVersion
3371 + }
3372 +
3373 + deviceInfo["sdk_version"] = "2.3.0"
3374 +
3375 + return deviceInfo
3376 + }
3377 +
3378 + /// Send device info to server if it has changed since last send
3379 + /// Equivalent of old ObjC `WLPushManager.sendDeviceInfoIfNecessary`
3380 + public func sendDeviceInfoIfNeeded() {
3381 + let currentDeviceInfo = buildDeviceInfo()
3382 +
3383 + // Compare with last sent device info
3384 + let oldDeviceInfo = UserDefaults.standard.dictionary(forKey: "warply_last_sent_device_info")
3385 + let hasChanged = !NSDictionary(dictionary: currentDeviceInfo).isEqual(to: oldDeviceInfo ?? [:])
3386 +
3387 + if hasChanged || !hasSentDeviceInfo {
3388 + print("📱 [WarplySDK] Device info has changed or not yet sent — sending to server")
3389 + sendDeviceInfo()
3390 + } else {
3391 + print("ℹ️ [WarplySDK] Device info unchanged — skipping send")
3392 + }
3393 + }
3394 +
3395 + /// Force send comprehensive device info to server
3396 + /// Equivalent of old ObjC `WLPushManager.sendDeviceInfo`
3397 + public func sendDeviceInfo() {
3398 + let deviceInfo = buildDeviceInfo()
3399 +
3400 + print("📤 [WarplySDK] Sending device info to server:")
3401 + print(" Device token: \((deviceInfo["device_token"] as? String)?.prefix(16) ?? "none")...")
3402 + print(" Platform: \(deviceInfo["platform"] ?? "unknown")")
3403 + print(" OS Version: \(deviceInfo["os_version"] ?? "unknown")")
3404 + print(" Model: \(deviceInfo["ios_model"] ?? "unknown")")
3405 + print(" Bundle ID: \(deviceInfo["bundle_identifier"] ?? "unknown")")
3406 +
3407 + // Wrap in event structure matching old ObjC format
3408 + let deviceInfoPayload: [String: Any] = [
3409 + "device_info": deviceInfo
3410 + ]
3411 +
3412 + let applicationData: [String: Any] = [
3413 + "bundle_identifier": deviceInfo["bundle_identifier"] ?? "",
3414 + "app_version": deviceInfo["app_version"] ?? "",
3415 + "app_build": deviceInfo["app_build"] ?? "",
3416 + "sdk_version": deviceInfo["sdk_version"] ?? ""
3417 + ]
3418 +
3419 + let applicationDataPayload: [String: Any] = [
3420 + "application_data": applicationData
3421 + ]
3422 +
3423 + Task {
3424 + // Send device_info event
3425 + await networkService.sendDeviceInfo(deviceInfoPayload)
3426 +
3427 + // Send application_data event
3428 + await networkService.sendDeviceInfo(applicationDataPayload)
3429 +
3430 + await MainActor.run {
3431 + self.hasSentDeviceInfo = true
3432 +
3433 + // Store last sent device info for change detection
3434 + UserDefaults.standard.set(deviceInfo, forKey: "warply_last_sent_device_info")
3435 +
3436 + print("✅ [WarplySDK] Device info sent to server successfully")
3437 + }
3438 + }
3439 + }
3440 +
3441 + // MARK: - Push Analytics
3442 +
3443 + /// Log push notification received analytics event
3444 + /// Equivalent of old ObjC `WLAnalyticsManager.logUserReceivedPush:`
3445 + /// - Parameter sessionUuid: The session UUID from the push notification
3446 + private func logPushReceived(sessionUuid: String) {
3447 + print("📊 [WarplySDK] Logging push received: NB_PushReceived (session: \(sessionUuid))")
3448 +
3449 + Task {
3450 + await networkService.sendEvent(
3451 + eventName: "NB_PushReceived",
3452 + priority: true
3453 + )
3454 + }
3455 + }
3456 +
3457 + /// Log push notification engaged analytics event
3458 + /// Equivalent of old ObjC `WLAnalyticsManager.logUserEngagedPush:`
3459 + /// - Parameter sessionUuid: The session UUID from the push notification
3460 + private func logPushEngaged(sessionUuid: String) {
3461 + print("📊 [WarplySDK] Logging push engaged: NB_PushAck (session: \(sessionUuid))")
3462 +
3463 + Task {
3464 + await networkService.sendEvent(
3465 + eventName: "NB_PushAck",
3466 + priority: true
3467 + )
3468 + }
3469 + }
3470 +
3471 + // MARK: - Rich Push Presentation
3472 +
3473 + /// Show alert when push received in foreground, then present campaign on confirmation
3474 + /// Equivalent of old ObjC UIAlertView in `didReceiveRemoteNotification:whileAppWasInState:` for `.active`
3475 + private func showPushAlertAndPresent(_ payload: WarplyNotificationPayload) {
3476 + guard let topVC = getTopViewController() else {
3477 + print("⚠️ [WarplySDK] Cannot show push alert — no top view controller found")
3478 + return
3479 + }
3480 +
3481 + let alertMessage = payload.message ?? "You have a new notification"
3482 +
3483 + let alert = UIAlertController(
3484 + title: alertMessage,
3485 + message: nil,
3486 + preferredStyle: .alert
3487 + )
3488 +
3489 + alert.addAction(UIAlertAction(title: NSLocalizedString("Close", comment: "Warply"), style: .cancel, handler: nil))
3490 + alert.addAction(UIAlertAction(title: NSLocalizedString("View", comment: "Warply"), style: .default, handler: { [weak self] _ in
3491 + // User chose to view — log engagement and show campaign
3492 + if let sessionUuid = payload.sessionUuid {
3493 + self?.logPushEngaged(sessionUuid: sessionUuid)
3494 + self?.showPushCampaign(sessionUuid: sessionUuid)
3495 + }
3496 + }))
3497 +
3498 + topVC.present(alert, animated: true, completion: nil)
3499 + }
3500 +
3501 + /// Present a campaign WebView for a push notification
3502 + /// Equivalent of old ObjC `WLPushManager.showItem:`
3503 + /// - Parameter sessionUuid: The session UUID from the push notification
3504 + public func showPushCampaign(sessionUuid: String) {
3505 + guard !sessionUuid.isEmpty else {
3506 + print("⚠️ [WarplySDK] Cannot show push campaign — empty session UUID")
3507 + return
3508 + }
3509 +
3510 + guard let topVC = getTopViewController() else {
3511 + print("⚠️ [WarplySDK] Cannot show push campaign — no top view controller found")
3512 + return
3513 + }
3514 +
3515 + // Build campaign URL: {baseURL}/api/session/{session_uuid}
3516 + let campaignUrl = "\(Configuration.baseURL)/api/session/\(sessionUuid)"
3517 +
3518 + // Build params JSON
3519 + let tempCampaign = CampaignItemModel()
3520 + tempCampaign.session_uuid = sessionUuid
3521 + let params = constructCampaignParams(tempCampaign)
3522 +
3523 + print("📱 [WarplySDK] Presenting push campaign WebView:")
3524 + print(" URL: \(campaignUrl)")
3525 + print(" Session UUID: \(sessionUuid)")
3526 +
3527 + // Create and present CampaignViewController
3528 + let campaignVC = CampaignViewController()
3529 + campaignVC.campaignUrl = campaignUrl
3530 + campaignVC.params = params
3531 + campaignVC.showHeader = false
3532 + campaignVC.isPresented = true
3533 +
3534 + let navController = UINavigationController(rootViewController: campaignVC)
3535 + navController.navigationBar.isHidden = true
3536 + navController.modalPresentationStyle = .fullScreen
3537 +
3538 + topVC.present(navController, animated: true, completion: nil)
3539 + }
3540 +
3541 + /// Handle pending notification that was received when app was closed
3542 + /// Call this from your root view controller's `viewDidAppear`
3543 + /// - Returns: `true` if a pending notification existed and was handled
3544 + @discardableResult
3545 + public func handlePendingNotification() -> Bool {
3546 + guard let pendingPayload = pendingNotificationPayload else {
3547 + return false
3548 + }
3549 +
3550 + print("📱 [WarplySDK] Handling pending notification (was stored when app was closed)")
3551 +
3552 + // Log engagement
3553 + if let sessionUuid = pendingPayload.sessionUuid {
3554 + logPushEngaged(sessionUuid: sessionUuid)
3555 + showPushCampaign(sessionUuid: sessionUuid)
3556 + }
3557 +
3558 + // Clear pending notification
3559 + pendingNotificationPayload = nil
3560 +
3561 + return true
3562 + }
3563 +
3564 + // MARK: - Push Notification Helpers
3565 +
3566 + /// Get the topmost presented view controller for modal presentation
3567 + /// - Returns: The topmost view controller, or nil if not found
3568 + private func getTopViewController() -> UIViewController? {
3569 + guard let windowScene = UIApplication.shared.connectedScenes
3570 + .compactMap({ $0 as? UIWindowScene })
3571 + .first(where: { $0.activationState == .foregroundActive }),
3572 + let window = windowScene.windows.first(where: { $0.isKeyWindow }),
3573 + var topController = window.rootViewController else {
3574 + return nil
3575 + }
3576 +
3577 + while let presented = topController.presentedViewController {
3578 + topController = presented
3579 + }
3580 +
3581 + return topController
3582 + }
3583 +
3584 + /// Handle launch options — check if app was launched from a push notification
3585 + /// Call this from `application(_:didFinishLaunchingWithOptions:)`
3586 + /// - Parameter launchOptions: The launch options dictionary
3587 + public func handleLaunchOptions(_ launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
3588 + guard let launchOptions = launchOptions else { return }
3589 +
3590 + // Check for remote notification in launch options
3591 + if let remoteNotification = launchOptions[.remoteNotification] as? [String: Any] {
3592 + print("📱 [WarplySDK] App launched from push notification")
3593 +
3594 + // Check if it's a Warply notification
3595 + if remoteNotification["_a"] != nil {
3596 + let payload = WarplyNotificationPayload(userInfo: remoteNotification)
3597 + pendingNotificationPayload = payload
3598 + print("📱 [WarplySDK] Warply push notification stored as pending (app was closed)")
3599 +
3600 + // Log received
3601 + if let sessionUuid = payload.sessionUuid {
3602 + logPushReceived(sessionUuid: sessionUuid)
3603 + }
3604 + }
3605 + }
3606 + }
3607 +
3608 + /// Reset the app badge number to 0
3609 + /// Equivalent of old ObjC `WLPushManager.resetBadge`
3610 + public func resetBadge() {
3611 + DispatchQueue.main.async {
3612 + UIApplication.shared.applicationIconBadgeNumber = 0
3613 + print("📱 [WarplySDK] Badge reset to 0")
3614 + }
3031 } 3615 }
3032 3616
3033 // MARK: - Event System (Public Access for Clients) 3617 // MARK: - Event System (Public Access for Clients)
......
...@@ -226,7 +226,7 @@ public struct SeasonalsRetrievedEvent: WarplyEvent { ...@@ -226,7 +226,7 @@ public struct SeasonalsRetrievedEvent: WarplyEvent {
226 226
227 /// Coupons fetched event 227 /// Coupons fetched event
228 public struct CouponsRetrievedEvent: WarplyEvent { 228 public struct CouponsRetrievedEvent: WarplyEvent {
229 - public let name: String = "coupons_fetched" 229 +public let name: String = "coupons_fetched"
230 public let timestamp: Date 230 public let timestamp: Date
231 public let data: Any? 231 public let data: Any?
232 232
...@@ -236,6 +236,40 @@ public struct CouponsRetrievedEvent: WarplyEvent { ...@@ -236,6 +236,40 @@ public struct CouponsRetrievedEvent: WarplyEvent {
236 } 236 }
237 } 237 }
238 238
239 +/// Event posted when a Warply push notification is received
240 +/// Equivalent of old ObjC `NB_PushReceived` analytics event
241 +public struct PushNotificationReceivedEvent: WarplyEvent {
242 + public let name: String = "push_notification_received"
243 + public let timestamp: Date
244 + public let data: Any?
245 +
246 + /// The session UUID from the push notification payload
247 + public let sessionUuid: String?
248 +
249 + public init(data: Any? = nil, sessionUuid: String? = nil) {
250 + self.timestamp = Date()
251 + self.data = data
252 + self.sessionUuid = sessionUuid
253 + }
254 +}
255 +
256 +/// Event posted when a user interacts with (engages) a Warply push notification
257 +/// Equivalent of old ObjC `NB_PushAck` analytics event
258 +public struct PushNotificationEngagedEvent: WarplyEvent {
259 + public let name: String = "push_notification_engaged"
260 + public let timestamp: Date
261 + public let data: Any?
262 +
263 + /// The session UUID from the push notification payload
264 + public let sessionUuid: String?
265 +
266 + public init(data: Any? = nil, sessionUuid: String? = nil) {
267 + self.timestamp = Date()
268 + self.data = data
269 + self.sessionUuid = sessionUuid
270 + }
271 +}
272 +
239 // MARK: - Convenience Extensions 273 // MARK: - Convenience Extensions
240 274
241 extension EventDispatcher { 275 extension EventDispatcher {
...@@ -275,4 +309,20 @@ extension EventDispatcher { ...@@ -275,4 +309,20 @@ extension EventDispatcher {
275 public func postCouponsRetrievedEvent(sender: Any? = nil) { 309 public func postCouponsRetrievedEvent(sender: Any? = nil) {
276 post(CouponsRetrievedEvent(data: sender)) 310 post(CouponsRetrievedEvent(data: sender))
277 } 311 }
312 +
313 + /// Post a push notification received event
314 + /// - Parameters:
315 + /// - sender: The event data (typically the raw push payload)
316 + /// - sessionUuid: The session UUID from the push notification
317 + public func postPushNotificationReceivedEvent(sender: Any? = nil, sessionUuid: String? = nil) {
318 + post(PushNotificationReceivedEvent(data: sender, sessionUuid: sessionUuid))
319 + }
320 +
321 + /// Post a push notification engaged event
322 + /// - Parameters:
323 + /// - sender: The event data (typically the raw push payload)
324 + /// - sessionUuid: The session UUID from the push notification
325 + public func postPushNotificationEngagedEvent(sender: Any? = nil, sessionUuid: String? = nil) {
326 + post(PushNotificationEngagedEvent(data: sender, sessionUuid: sessionUuid))
327 + }
278 } 328 }
......
...@@ -97,7 +97,7 @@ public enum Endpoint { ...@@ -97,7 +97,7 @@ public enum Endpoint {
97 case sendEvent(eventName: String, priority: Bool) 97 case sendEvent(eventName: String, priority: Bool)
98 98
99 // Device 99 // Device
100 - case sendDeviceInfo(deviceToken: String) 100 + case sendDeviceInfo(deviceInfo: [String: Any])
101 101
102 // Network status 102 // Network status
103 case getNetworkStatus 103 case getNetworkStatus
...@@ -428,13 +428,10 @@ public enum Endpoint { ...@@ -428,13 +428,10 @@ public enum Endpoint {
428 ] 428 ]
429 ] 429 ]
430 430
431 - // Device Info endpoints - device structure 431 + // Device Info endpoints - comprehensive device info structure
432 - case .sendDeviceInfo(let deviceToken): 432 + // Matches old ObjC WLPushManager.deviceInfo + WLPushManager.applicationData
433 - return [ 433 + case .sendDeviceInfo(let deviceInfo):
434 - "device": [ 434 + return deviceInfo
435 - "device_token": deviceToken
436 - ]
437 - ]
438 435
439 // Network status - no body needed 436 // Network status - no body needed
440 case .getNetworkStatus: 437 case .getNetworkStatus:
......
...@@ -860,14 +860,16 @@ extension NetworkService { ...@@ -860,14 +860,16 @@ extension NetworkService {
860 } 860 }
861 } 861 }
862 862
863 - /// Update device token 863 + /// Send comprehensive device info to server
864 - public func updateDeviceToken(_ deviceToken: String) async { 864 + /// - Parameter deviceInfo: Full device info dictionary (built by WarplySDK.buildDeviceInfo())
865 - let endpoint = Endpoint.sendDeviceInfo(deviceToken: deviceToken) 865 + public func sendDeviceInfo(_ deviceInfo: [String: Any]) async {
866 + let endpoint = Endpoint.sendDeviceInfo(deviceInfo: deviceInfo)
866 867
867 do { 868 do {
868 _ = try await requestRaw(endpoint) 869 _ = try await requestRaw(endpoint)
870 + print("✅ [NetworkService] Device info sent to server successfully")
869 } catch { 871 } catch {
870 - print("Failed to update device token: \(error)") 872 + print("❌ [NetworkService] Failed to send device info: \(error)")
871 } 873 }
872 } 874 }
873 875
......
...@@ -496,6 +496,71 @@ self.postFrameworkEvent("dynatrace", sender: dynatraceEvent) ...@@ -496,6 +496,71 @@ self.postFrameworkEvent("dynatrace", sender: dynatraceEvent)
496 ``` 496 ```
497 Event naming convention: `custom_{success|error}_{operation}_loyalty` 497 Event naming convention: `custom_{success|error}_{operation}_loyalty`
498 498
499 +## 9.5 Push Notification System
500 +
501 +### Architecture (Option A: Host App Forwards)
502 +
503 +The framework does **NOT** set itself as `UNUserNotificationCenterDelegate`. Instead, the host app owns the delegate and forwards calls to the SDK. This avoids conflicts with Firebase, OneSignal, etc.
504 +
505 +### Push Notification Types
506 +
507 +| Type | Location | Purpose |
508 +|------|----------|---------|
509 +| `WarplyApplicationState` | `WarplySDK.swift` | Enum: `.active`, `.background`, `.closed` |
510 +| `WarplyPushHandler` | `WarplySDK.swift` | Protocol for custom push handling (action != 0) |
511 +| `WarplyNotificationPayload` | `WarplySDK.swift` | Parsed push payload (action, sessionUuid, message, aps, customData) |
512 +| `WarplyAPSItem` | `WarplySDK.swift` | Parsed APS dictionary (alert, badge, sound, category) |
513 +| `PushNotificationReceivedEvent` | `EventDispatcher.swift` | Event posted when push received |
514 +| `PushNotificationEngagedEvent` | `EventDispatcher.swift` | Event posted when user interacts with push |
515 +
516 +### Push Notification Detection
517 +
518 +Warply push notifications are identified by the `"_a"` key in the payload:
519 +```json
520 +{"_a": 0, "session_uuid": "abc-123", "aps": {"alert": "New offer!", "badge": 1}}
521 +```
522 +- `"_a" == 0`: Default handling — framework presents `CampaignViewController` WebView
523 +- `"_a" != 0`: Custom action — forwarded to `pushHandlerDelegate`
524 +
525 +### Push Notification Methods
526 +
527 +| Method | Purpose |
528 +|--------|---------|
529 +| `updateDeviceToken(_:)` | Store token + send comprehensive device info to server |
530 +| `checkForLoyaltySDKNotification(_:)` | Detect Warply push by `"_a"` key, auto-handle if found |
531 +| `handleNotification(_:appState:)` | Route notification by action and app state |
532 +| `handleLaunchOptions(_:)` | Check launch options for push (cold start) |
533 +| `handlePendingNotification()` | Process stored notification from cold start |
534 +| `showPushCampaign(sessionUuid:)` | Present campaign WebView for a session UUID |
535 +| `resetBadge()` | Reset app icon badge to 0 |
536 +| `sendDeviceInfo()` | Force send device info to server |
537 +| `sendDeviceInfoIfNeeded()` | Smart send — only if info changed |
538 +| `buildDeviceInfo()` | Build comprehensive device info dictionary |
539 +
540 +### Push Analytics
541 +
542 +| Event Name | When Sent | Equivalent ObjC |
543 +|------------|-----------|-----------------|
544 +| `NB_PushReceived` | When Warply push is received | `logUserReceivedPush:` |
545 +| `NB_PushAck` | When user engages with push | `logUserEngagedPush:` |
546 +
547 +### Device Info Payload
548 +
549 +Sent to `/api/async/info/{appUUID}/` via `Endpoint.sendDeviceInfo`. Contains:
550 +- `device_token`, `platform` ("ios"), `vendor` ("apple"), `os_version`
551 +- `unique_device_id` (IDFV), `ios_system_name`, `ios_system_version`, `ios_model`
552 +- `ios_locale`, `ios_languages`, `screen_resolution`
553 +- `bundle_identifier`, `app_version`, `app_build`, `sdk_version`
554 +- `development` flag (DEBUG vs release)
555 +
556 +### Rich Push Presentation
557 +
558 +For `action == 0` pushes, the framework presents `CampaignViewController` internally:
559 +- URL: `{baseURL}/api/session/{session_uuid}`
560 +- **Active state**: Shows alert first, then WebView on user confirmation
561 +- **Background state**: Presents WebView immediately
562 +- **Closed state**: Stores as pending, shows via `handlePendingNotification()`
563 +
499 ## 10. Configuration System 564 ## 10. Configuration System
500 565
501 ### WarplyConfiguration (`Configuration/WarplyConfiguration.swift`) 566 ### WarplyConfiguration (`Configuration/WarplyConfiguration.swift`)
......