Showing
8 changed files
with
1196 additions
and
52 deletions
| ... | @@ -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 |
| 1257 | - let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() | 1259 | + |
| 1258 | - WarplySDK.shared.updateDeviceToken(tokenString) | 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) { | ||
| 1289 | + let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() | ||
| 1290 | + WarplySDK.shared.updateDeviceToken(tokenString) | ||
| 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] ?? [:] | ||
| 1296 | + | ||
| 1297 | + if WarplySDK.shared.checkForLoyaltySDKNotification(userInfo) { | ||
| 1298 | + // Warply handles it (shows alert in foreground) | ||
| 1299 | + completionHandler([]) | ||
| 1300 | + } else { | ||
| 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() | ||
| 1314 | + } | ||
| 1259 | } | 1315 | } |
| 1316 | +``` | ||
| 1260 | 1317 | ||
| 1261 | -func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) { | 1318 | +### Handle Pending Notifications |
| 1262 | - // Check if it's a Warply notification | 1319 | + |
| 1263 | - if WarplySDK.shared.checkForLoyaltySDKNotification(userInfo) { | 1320 | +When the app is launched from a push notification (cold start), call this in your root view controller: |
| 1264 | - print("Warply notification handled") | 1321 | + |
| 1265 | - } else { | 1322 | +```swift |
| 1266 | - // Handle other notifications | 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() | ||
| 1267 | } | 1329 | } |
| 1268 | } | 1330 | } |
| 1269 | ``` | 1331 | ``` |
| 1270 | 1332 | ||
| 1271 | -### Handle Notifications | 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: | ||
| 1272 | 1336 | ||
| 1273 | ```swift | 1337 | ```swift |
| 1274 | -// Manual notification handling | 1338 | +// In AppDelegate — clear the badge when user opens the app |
| 1275 | -WarplySDK.shared.handleNotification(notificationPayload) | 1339 | +func applicationDidBecomeActive(_ application: UIApplication) { |
| 1340 | + WarplySDK.shared.resetBadge() | ||
| 1341 | +} | ||
| 1342 | +``` | ||
| 1343 | + | ||
| 1344 | +### Optional — Listen for Push Events | ||
| 1276 | 1345 | ||
| 1277 | -// Check if notification belongs to Warply SDK | 1346 | +If you want to react to push notifications in your own code (in addition to the SDK's automatic handling): |
| 1278 | -let isWarplyNotification = WarplySDK.shared.checkForLoyaltySDKNotification(payload) | 1347 | + |
| 1348 | +```swift | ||
| 1349 | +// Modern EventDispatcher approach | ||
| 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) | ||
| 1356 | + | ||
| 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: | ||
| 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 | ... | ... |
PUSH_NOTIFICATIONS_MIGRATION_PLAN.md
0 → 100644
| 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 | ||
| 3225 | + } | ||
| 3226 | + | ||
| 3227 | + print("📱 [WarplySDK] Handling Warply notification:") | ||
| 3228 | + print(" Action: \(notificationPayload.action)") | ||
| 3229 | + print(" Session UUID: \(notificationPayload.sessionUuid ?? "nil")") | ||
| 3230 | + print(" Message: \(notificationPayload.message ?? "nil")") | ||
| 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) | ||
| 3240 | + | ||
| 3241 | + // Route based on action | ||
| 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) | ||
| 3006 | } | 3271 | } |
| 3007 | 3272 | ||
| 3008 | - /// Check for loyalty SDK notification | 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 | ||
| 3009 | public func checkForLoyaltySDKNotification(_ payload: [String: Any]) -> Bool { | 3277 | public func checkForLoyaltySDKNotification(_ payload: [String: Any]) -> Bool { |
| 3010 | - // Pure Swift notification checking - basic implementation | 3278 | + // Check for "_a" key — the actual Warply push notification identifier |
| 3011 | - // TODO: Implement proper notification checking logic | 3279 | + // (Old ObjC code: [userInfo valueForKey:@"_a"]) |
| 3012 | - print("Checking for loyalty SDK notification: \(payload)") | 3280 | + if payload["_a"] != nil { |
| 3013 | - | 3281 | + print("✅ [WarplySDK] Warply push notification detected (contains '_a' key)") |
| 3014 | - // For now, assume it's a loyalty SDK notification if it contains certain keys | ||
| 3015 | - if payload["loyalty_sdk"] != nil || payload["warply"] != nil { | ||
| 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,16 +226,50 @@ public struct SeasonalsRetrievedEvent: WarplyEvent { | ... | @@ -226,16 +226,50 @@ 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 | + |
| 233 | public init(data: Any? = nil) { | 233 | public init(data: Any? = nil) { |
| 234 | self.timestamp = Date() | 234 | self.timestamp = Date() |
| 235 | self.data = data | 235 | self.data = data |
| 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`) | ... | ... |
-
Please register or login to post a comment