appambit-push-notifications 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/AppAmbitSdkPushNotifications.podspec +14 -4
  2. package/README.md +184 -105
  3. package/android/build.gradle +21 -3
  4. package/android/src/main/AndroidManifest.xml +107 -1
  5. package/android/src/main/java/com/appambitpushnotifications/AppAmbitContextHolder.kt +22 -0
  6. package/android/src/main/java/com/appambitpushnotifications/AppAmbitHeadlessService.kt +177 -0
  7. package/android/src/main/java/com/appambitpushnotifications/AppAmbitInitProvider.kt +73 -0
  8. package/android/src/main/java/com/appambitpushnotifications/AppAmbitMessagingService.kt +12 -0
  9. package/android/src/main/java/com/appambitpushnotifications/AppAmbitNotificationSerializer.kt +88 -0
  10. package/android/src/main/java/com/appambitpushnotifications/AppAmbitPayloadUtils.kt +59 -0
  11. package/android/src/main/java/com/appambitpushnotifications/AppAmbitPushEventEmitter.kt +100 -0
  12. package/android/src/main/java/com/appambitpushnotifications/AppAmbitRNServiceExtension.kt +75 -0
  13. package/android/src/main/java/com/appambitpushnotifications/AppAmbitRemoteMessageStore.kt +26 -0
  14. package/android/src/main/java/com/appambitpushnotifications/AppambitPushNotificationsModule.kt +377 -76
  15. package/ios/AppAmbitNotificationSwizzler.m +290 -0
  16. package/ios/AppAmbitPushWrapper.swift +165 -25
  17. package/ios/AppAmbitRNNotificationService.swift +46 -0
  18. package/ios/AppambitPushNotifications.mm +264 -10
  19. package/lib/module/NativeAppambitPushNotifications.js.map +1 -1
  20. package/lib/module/index.js +46 -10
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/typescript/src/NativeAppambitPushNotifications.d.ts +2 -1
  23. package/lib/typescript/src/NativeAppambitPushNotifications.d.ts.map +1 -1
  24. package/lib/typescript/src/index.d.ts +32 -6
  25. package/lib/typescript/src/index.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/NativeAppambitPushNotifications.ts +7 -1
  28. package/src/index.tsx +93 -20
@@ -0,0 +1,290 @@
1
+ #import <UIKit/UIKit.h>
2
+ #import <UserNotifications/UserNotifications.h>
3
+ #import <objc/runtime.h>
4
+ #import <AppAmbitSdkPushNotifications-Swift.h>
5
+
6
+ // ─── cold-start deduplication ─────────────────────────────────────────────────
7
+ //
8
+ // On a cold-start notification tap (app was killed), BOTH paths fire:
9
+ // 1. appDidFinishLaunching: → launchOptions contains the payload (100% reliable)
10
+ // 2. userNotificationCenter:didReceiveNotificationResponse: fires afterward
11
+ //
12
+ // We capture via (1) and use this flag to suppress the duplicate in (2).
13
+ // For background→foreground taps, (1) does not fire, so the flag stays NO
14
+ // and (2) is the only capture path.
15
+
16
+ static BOOL _capturedFromLaunchOptions = NO;
17
+
18
+ // ─── background remote notification swizzle ──────────────────────────────────
19
+
20
+ typedef void (^RemoteNotificationCompletionHandler)(UIBackgroundFetchResult);
21
+ typedef void (*RemoteNotificationIMPType)(id, SEL, UIApplication *, NSDictionary *, RemoteNotificationCompletionHandler);
22
+
23
+ static RemoteNotificationIMPType _originalRemoteNotificationIMP = NULL;
24
+
25
+ // ─── JS background handler completion ────────────────────────────────────────
26
+ //
27
+ // When no original AppDelegate implementation exists, we own the completion
28
+ // handler. We hold it here until JS calls backgroundHandlerCompleted(), which
29
+ // routes to AppAmbitNotifyJSBackgroundHandlerCompleted(). A 25-second safety
30
+ // timeout fires if JS never resolves, and the OS expiration handler fires if
31
+ // iOS needs the time back sooner.
32
+
33
+ static void (^_pendingBGCompletionBlock)(void) = nil;
34
+ static UIBackgroundTaskIdentifier _pendingBGTask = 0; // UIBackgroundTaskInvalid == 0; can't use extern const as static initializer
35
+ static dispatch_block_t _pendingBGSafetyTimer = nil;
36
+
37
+ static void AppAmbitFinishBackgroundTask(void) {
38
+ if (_pendingBGCompletionBlock) {
39
+ void (^block)(void) = _pendingBGCompletionBlock;
40
+ _pendingBGCompletionBlock = nil;
41
+ block();
42
+ }
43
+ if (_pendingBGSafetyTimer) {
44
+ dispatch_block_cancel(_pendingBGSafetyTimer);
45
+ _pendingBGSafetyTimer = nil;
46
+ }
47
+ if (_pendingBGTask != UIBackgroundTaskInvalid) {
48
+ [[UIApplication sharedApplication] endBackgroundTask:_pendingBGTask];
49
+ _pendingBGTask = UIBackgroundTaskInvalid;
50
+ }
51
+ }
52
+
53
+ // Called from AppambitPushNotifications.mm via the extern declaration.
54
+ void AppAmbitNotifyJSBackgroundHandlerCompleted(void) {
55
+ dispatch_async(dispatch_get_main_queue(), ^{
56
+ AppAmbitFinishBackgroundTask();
57
+ });
58
+ }
59
+
60
+ static void AppAmbitRemoteNotificationIMP(
61
+ id self,
62
+ SEL _cmd,
63
+ UIApplication *application,
64
+ NSDictionary *userInfo,
65
+ RemoteNotificationCompletionHandler completionHandler
66
+ ) {
67
+ [AppAmbitPushWrapper didReceiveBackgroundNotification:userInfo];
68
+
69
+ if (_originalRemoteNotificationIMP != NULL) {
70
+ // AppDelegate already handles background time; call through.
71
+ _originalRemoteNotificationIMP(self, _cmd, application, userInfo, completionHandler);
72
+ return;
73
+ }
74
+
75
+ // No original implementation — we own the completion handler.
76
+ // Flush any previous task that JS never completed (shouldn't happen normally).
77
+ AppAmbitFinishBackgroundTask();
78
+
79
+ _pendingBGCompletionBlock = ^{
80
+ completionHandler(UIBackgroundFetchResultNewData);
81
+ };
82
+
83
+ // iOS expiration handler: system needs time back immediately.
84
+ _pendingBGTask = [application beginBackgroundTaskWithExpirationHandler:^{
85
+ AppAmbitFinishBackgroundTask();
86
+ }];
87
+
88
+ // Safety timeout: if JS doesn't call backgroundHandlerCompleted within 25s,
89
+ // complete anyway so future background wake-ups are not penalised by iOS.
90
+ dispatch_block_t timer = dispatch_block_create(DISPATCH_BLOCK_INHERIT_QOS_CLASS, ^{
91
+ AppAmbitFinishBackgroundTask();
92
+ });
93
+ _pendingBGSafetyTimer = timer;
94
+ dispatch_after(
95
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(25.0 * NSEC_PER_SEC)),
96
+ dispatch_get_main_queue(),
97
+ timer
98
+ );
99
+ }
100
+
101
+ // ─── UNUserNotificationCenter delegate proxy ─────────────────────────────────
102
+
103
+ @interface AppAmbitNotificationProxy : NSObject <UNUserNotificationCenterDelegate>
104
+ @property (nonatomic, weak) id<UNUserNotificationCenterDelegate> next;
105
+ + (instancetype)shared;
106
+ @end
107
+
108
+ @implementation AppAmbitNotificationProxy
109
+
110
+ + (instancetype)shared {
111
+ static AppAmbitNotificationProxy *instance = nil;
112
+ static dispatch_once_t onceToken;
113
+ dispatch_once(&onceToken, ^{ instance = [[AppAmbitNotificationProxy alloc] init]; });
114
+ return instance;
115
+ }
116
+
117
+ // Show notification banner/sound/badge when app is foreground
118
+ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
119
+ willPresentNotification:(UNNotification *)notification
120
+ withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
121
+ SEL sel = @selector(userNotificationCenter:willPresentNotification:withCompletionHandler:);
122
+ if ([self.next respondsToSelector:sel]) {
123
+ [self.next userNotificationCenter:center
124
+ willPresentNotification:notification
125
+ withCompletionHandler:completionHandler];
126
+ } else {
127
+ if (@available(iOS 14.0, *)) {
128
+ completionHandler(UNNotificationPresentationOptionBanner |
129
+ UNNotificationPresentationOptionBadge |
130
+ UNNotificationPresentationOptionSound);
131
+ } else {
132
+ completionHandler(UNNotificationPresentationOptionAlert |
133
+ UNNotificationPresentationOptionBadge |
134
+ UNNotificationPresentationOptionSound);
135
+ }
136
+ }
137
+ }
138
+
139
+ // Notification tap — background→foreground taps are captured here.
140
+ // Cold-start taps are captured via launchOptions in appDidFinishLaunching: instead;
141
+ // this path deduplicates them using _capturedFromLaunchOptions.
142
+ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
143
+ didReceiveNotificationResponse:(UNNotificationResponse *)response
144
+ withCompletionHandler:(void (^)(void))completionHandler {
145
+
146
+ if (_capturedFromLaunchOptions) {
147
+ // Cold-start: already captured via launchOptions — skip to avoid duplicate.
148
+ _capturedFromLaunchOptions = NO;
149
+ } else {
150
+ // Background→foreground tap: not captured yet, capture now.
151
+ NSDictionary *userInfo = response.notification.request.content.userInfo;
152
+ [AppAmbitPushWrapper didReceiveOpenedNotification:userInfo];
153
+ }
154
+
155
+ SEL sel = @selector(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:);
156
+ if ([self.next respondsToSelector:sel]) {
157
+ [self.next userNotificationCenter:center
158
+ didReceiveNotificationResponse:response
159
+ withCompletionHandler:completionHandler];
160
+ } else {
161
+ completionHandler();
162
+ }
163
+ }
164
+
165
+ @end
166
+
167
+ // ─── setDelegate: swizzle — pins proxy as permanent delegate ──────────────────
168
+ //
169
+ // React Native (RCTPushNotificationManager) and the AppAmbit native SDK both
170
+ // call [UNUserNotificationCenter.current setDelegate:] during app startup.
171
+ // Without this swizzle those calls would evict our proxy.
172
+ //
173
+ // With this swizzle, ANY setDelegate: call from ANY caller is intercepted:
174
+ // • the caller's delegate becomes proxy.next (chaining preserved)
175
+ // • proxy is (re-)installed as the actual delegate
176
+ // The proxy forwards both delegate callbacks to proxy.next, so RN and the
177
+ // native SDK still receive all the notifications they expect.
178
+
179
+ typedef void (*SetDelegateIMPType)(id, SEL, id<UNUserNotificationCenterDelegate>);
180
+ static SetDelegateIMPType _originalSetDelegateIMP = NULL;
181
+
182
+ static void AppAmbitSetDelegateIMP(
183
+ UNUserNotificationCenter *center,
184
+ SEL _cmd,
185
+ id<UNUserNotificationCenterDelegate> newDelegate
186
+ ) {
187
+ AppAmbitNotificationProxy *proxy = [AppAmbitNotificationProxy shared];
188
+ if (newDelegate == proxy) {
189
+ _originalSetDelegateIMP(center, _cmd, newDelegate);
190
+ return;
191
+ }
192
+ proxy.next = newDelegate;
193
+ _originalSetDelegateIMP(center, _cmd, proxy);
194
+ }
195
+
196
+ // ─── main swizzler ────────────────────────────────────────────────────────────
197
+
198
+ @interface AppAmbitNotificationSwizzler : NSObject
199
+ @end
200
+
201
+ @implementation AppAmbitNotificationSwizzler
202
+
203
+ + (void)load {
204
+ static dispatch_once_t onceToken;
205
+ dispatch_once(&onceToken, ^{
206
+ // Swizzle UNUserNotificationCenter.setDelegate: so proxy stays pinned
207
+ // regardless of when RN or the native SDK set their own delegates.
208
+ Class uncClass = [UNUserNotificationCenter class];
209
+ SEL setDelegateSel = @selector(setDelegate:);
210
+ Method setDelegateMethod = class_getInstanceMethod(uncClass, setDelegateSel);
211
+ if (setDelegateMethod) {
212
+ _originalSetDelegateIMP = (SetDelegateIMPType)method_getImplementation(setDelegateMethod);
213
+ method_setImplementation(setDelegateMethod, (IMP)AppAmbitSetDelegateIMP);
214
+ }
215
+
216
+ [[NSNotificationCenter defaultCenter]
217
+ addObserver:self
218
+ selector:@selector(appWillFinishLaunching:)
219
+ name:@"UIApplicationWillFinishLaunchingNotification"
220
+ object:nil];
221
+
222
+ [[NSNotificationCenter defaultCenter]
223
+ addObserver:self
224
+ selector:@selector(appDidFinishLaunching:)
225
+ name:UIApplicationDidFinishLaunchingNotification
226
+ object:nil];
227
+ });
228
+ }
229
+
230
+ + (void)appWillFinishLaunching:(NSNotification *)notification {
231
+ [[NSNotificationCenter defaultCenter] removeObserver:self
232
+ name:@"UIApplicationWillFinishLaunchingNotification"
233
+ object:nil];
234
+
235
+ // Start native SDK — if it calls setDelegate:, the swizzle intercepts and
236
+ // chains its delegate as proxy.next automatically.
237
+ [AppAmbitPushWrapper start];
238
+
239
+ // Ensure proxy is the actual delegate. If the SDK called setDelegate:
240
+ // the swizzle already installed proxy; this guard is a no-op in that case.
241
+ AppAmbitNotificationProxy *proxy = [AppAmbitNotificationProxy shared];
242
+ if (UNUserNotificationCenter.currentNotificationCenter.delegate != proxy) {
243
+ UNUserNotificationCenter.currentNotificationCenter.delegate = proxy;
244
+ }
245
+
246
+ // ── Cold-start notification tap capture ───────────────────────────────────
247
+ // UIApplicationWillFinishLaunchingNotification carries the same dictionary
248
+ // that was passed as launchOptions to application:willFinishLaunchingWithOptions:.
249
+ // We capture it here early, BEFORE the React Native bridge and its modules
250
+ // are initialized, so the module's init can successfully retrieve it.
251
+ NSDictionary *launchOptions = notification.userInfo;
252
+ NSDictionary *remoteNotification = launchOptions[UIApplicationLaunchOptionsRemoteNotificationKey];
253
+ if (remoteNotification) {
254
+ _capturedFromLaunchOptions = YES;
255
+ [AppAmbitPushWrapper didReceiveOpenedNotification:remoteNotification];
256
+ }
257
+ }
258
+
259
+ + (void)appDidFinishLaunching:(NSNotification *)notification {
260
+ [[NSNotificationCenter defaultCenter] removeObserver:self
261
+ name:UIApplicationDidFinishLaunchingNotification
262
+ object:nil];
263
+
264
+ // ── Cold-start notification tap capture (fallback) ───────────────────────
265
+ if (!_capturedFromLaunchOptions) {
266
+ NSDictionary *launchOptions = notification.userInfo;
267
+ NSDictionary *remoteNotification = launchOptions[UIApplicationLaunchOptionsRemoteNotificationKey];
268
+ if (remoteNotification) {
269
+ _capturedFromLaunchOptions = YES;
270
+ [AppAmbitPushWrapper didReceiveOpenedNotification:remoteNotification];
271
+ }
272
+ }
273
+
274
+ id<UIApplicationDelegate> delegate = UIApplication.sharedApplication.delegate;
275
+ if (!delegate) return;
276
+
277
+ Class delegateClass = object_getClass(delegate);
278
+
279
+ // ── Background remote notification swizzle ────────────────────────────────
280
+ SEL selector = @selector(application:didReceiveRemoteNotification:fetchCompletionHandler:);
281
+ Method existingMethod = class_getInstanceMethod(delegateClass, selector);
282
+ if (existingMethod) {
283
+ _originalRemoteNotificationIMP = (RemoteNotificationIMPType)method_getImplementation(existingMethod);
284
+ method_setImplementation(existingMethod, (IMP)AppAmbitRemoteNotificationIMP);
285
+ } else {
286
+ class_addMethod(delegateClass, selector, (IMP)AppAmbitRemoteNotificationIMP, "v@:@@@?");
287
+ }
288
+ }
289
+
290
+ @end
@@ -1,18 +1,76 @@
1
1
  import Foundation
2
2
  import UserNotifications
3
+ import Network
3
4
  import AppAmbitPushNotifications
5
+ import AppAmbit
4
6
 
5
7
  @objc(AppAmbitPushWrapper)
6
8
  public class AppAmbitPushWrapper: NSObject {
7
9
 
10
+ // MARK: - Network monitoring
11
+ //
12
+ // The AppAmbit SDK's ConsumerService has no offline retry queue and dedups
13
+ // consumer updates against its local DB, so an update attempted while offline
14
+ // poisons that cache and is never re-sent. We therefore gate consumer updates
15
+ // on real connectivity and replay them when the network returns.
16
+ private static let pathMonitor = NWPathMonitor()
17
+ private static let monitorQueue = DispatchQueue(label: "com.appambit.push.netmonitor")
18
+ private static var monitorStarted = false
19
+ private static var lastSatisfied = false
20
+
21
+ @objc public static func startNetworkMonitor() {
22
+ if monitorStarted { return }
23
+ monitorStarted = true
24
+ pathMonitor.pathUpdateHandler = { path in
25
+ let satisfied = path.status == .satisfied
26
+ let becameAvailable = satisfied && !lastSatisfied
27
+ lastSatisfied = satisfied
28
+ if becameAvailable {
29
+ NotificationCenter.default.post(
30
+ name: NSNotification.Name("AppAmbit_networkAvailable"),
31
+ object: nil
32
+ )
33
+ }
34
+ }
35
+ pathMonitor.start(queue: monitorQueue)
36
+ }
37
+
38
+ @objc public static func isNetworkAvailable() -> Bool {
39
+ if !monitorStarted { startNetworkMonitor() }
40
+ return pathMonitor.currentPath.status == .satisfied
41
+ }
42
+
8
43
  @objc public static func start() {
44
+ startNetworkMonitor()
45
+ // In Debug builds enable the SDK's verbose logging so its native logs are
46
+ // visible in the simulator/device log stream during development.
47
+ #if DEBUG
48
+ PushNotifications.start(debugMode: true)
49
+ #else
9
50
  PushNotifications.start()
51
+ #endif
10
52
  }
11
53
 
12
54
  @objc public static func setNotificationsEnabled(_ enabled: Bool) {
13
55
  PushNotifications.setNotificationsEnabled(enabled)
14
56
  }
15
57
 
58
+ /// Persists the enabled flag locally (UserDefaults only — no network call).
59
+ /// Used so the SDK's cold-start token sync knows the user's intent without
60
+ /// triggering an offline-poisoning consumer update.
61
+ @objc public static func setNotificationsEnabledLocal(_ enabled: Bool) {
62
+ PushKernel.setNotificationsEnabled(enabled)
63
+ }
64
+
65
+ /// Completion-based variant: reports whether the backend network call succeeded.
66
+ /// Used by the offline-retry path so it knows when to clear the pending flag.
67
+ @objc(setNotificationsEnabled:completion:)
68
+ public static func setNotificationsEnabled(_ enabled: Bool, completion: @escaping (Bool) -> Void) {
69
+ PushKernel.setNotificationsEnabled(enabled)
70
+ let token = PushKernel.getCurrentToken()
71
+ ConsumerService.shared.updateConsumer(deviceToken: token, pushEnabled: enabled, completion: completion)
72
+ }
73
+
16
74
  @objc public static func isNotificationsEnabled() -> Bool {
17
75
  return PushNotifications.isNotificationsEnabled()
18
76
  }
@@ -21,38 +79,120 @@ public class AppAmbitPushWrapper: NSObject {
21
79
  PushNotifications.requestNotificationPermission(listener: listener)
22
80
  }
23
81
 
24
- @objc(setNotificationCustomizer:)
25
- public static func setNotificationCustomizer(listener: ((NSDictionary) -> Void)?) {
26
- PushNotifications.setNotificationCustomizer { notification in
27
- let content = notification.request.content
28
- let userInfo = content.userInfo
82
+ @objc public static func hasNotificationPermission(completion: @escaping (Bool) -> Void) {
83
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
84
+ let granted = settings.authorizationStatus == .authorized
85
+ || settings.authorizationStatus == .provisional
86
+ if granted {
87
+ // Cache the grant so we can survive simulator reinstalls where the
88
+ // system returns .notDetermined even though permission was given before.
89
+ UserDefaults.standard.set(true, forKey: "appambit_push_has_permission")
90
+ }
91
+ // If the system says .denied, the user explicitly revoked — always return false.
92
+ // If the system says .notDetermined AND we have a cached grant, the app was
93
+ // likely reinstalled (common on iOS Simulator) — return true as fallback.
94
+ let fallback = settings.authorizationStatus == .notDetermined
95
+ && UserDefaults.standard.bool(forKey: "appambit_push_has_permission")
96
+ completion(granted || fallback)
97
+ }
98
+ }
29
99
 
30
- var notificationData: [String: String] = [
31
- "title": content.title,
32
- "body": content.body
33
- ]
100
+ @objc(setNotificationListener:)
101
+ public static func setNotificationListener(listener: @escaping ((NSDictionary, Int) -> Void)) {
102
+ PushNotifications.setNotificationListener { userInfo, state in
103
+ let payload = formatNotificationPayload(userInfo)
104
+ listener(payload as NSDictionary, state.rawValue)
105
+ }
106
+ }
34
107
 
35
- if !content.subtitle.isEmpty {
36
- notificationData["subtitle"] = content.subtitle
37
- }
108
+ public static var pendingBackgroundPayloads: [[String: Any]] = []
38
109
 
39
- var customData: [String: String] = [:]
40
- for (key, value) in userInfo {
41
- guard let keyStr = key as? String else { continue }
42
- // Exclude only the standard Apple payload. We keep the rest to see exactly what comes.
43
- if keyStr == "aps" { continue }
44
- customData[keyStr] = "\(value)"
45
- }
110
+ public static var pendingOpenedPayloads: [[String: Any]] = []
111
+
112
+ @objc(didReceiveBackgroundNotification:)
113
+ public static func didReceiveBackgroundNotification(_ userInfo: [AnyHashable: Any]) {
114
+ let payload = formatNotificationPayload(userInfo)
115
+ pendingBackgroundPayloads.append(payload)
116
+
117
+ NotificationCenter.default.post(
118
+ name: NSNotification.Name("AppAmbit_onBackgroundNotification"),
119
+ object: nil,
120
+ userInfo: payload
121
+ )
122
+ }
123
+
124
+ @objc(didReceiveOpenedNotification:)
125
+ public static func didReceiveOpenedNotification(_ userInfo: [AnyHashable: Any]) {
126
+ let payload = formatNotificationPayload(userInfo)
127
+ pendingOpenedPayloads.append(payload)
128
+
129
+ NotificationCenter.default.post(
130
+ name: NSNotification.Name("AppAmbit_onOpenedNotification"),
131
+ object: nil,
132
+ userInfo: payload
133
+ )
134
+ }
135
+
136
+ @objc public static func getAndClearPendingBackgroundPayloads() -> [[String: Any]] {
137
+ let payloads = pendingBackgroundPayloads
138
+ pendingBackgroundPayloads.removeAll()
139
+ return payloads
140
+ }
46
141
 
47
- var payload: [String: Any] = [
48
- "notification": notificationData
49
- ]
142
+ @objc public static func getAndClearPendingOpenedPayloads() -> [[String: Any]] {
143
+ let payloads = pendingOpenedPayloads
144
+ pendingOpenedPayloads.removeAll()
145
+ return payloads
146
+ }
147
+
148
+ @objc(formatNotificationPayload:)
149
+ public static func formatNotificationPayload(_ userInfo: [AnyHashable: Any]) -> [String: Any] {
150
+ var title: String? = nil
151
+ var body: String? = nil
152
+ var imageUrl: String? = nil
153
+ var customData: [String: Any] = [:]
50
154
 
51
- if !customData.isEmpty {
52
- payload["data"] = customData
155
+ let aps = userInfo["aps"] as? [String: Any]
156
+ if let aps = aps {
157
+ if let alert = aps["alert"] as? [String: Any] {
158
+ title = alert["title"] as? String
159
+ body = alert["body"] as? String
160
+ } else if let alertStr = aps["alert"] as? String {
161
+ body = alertStr
53
162
  }
163
+ }
164
+
165
+ let imageUrlKeys: Set<String> = ["image_url", "imageUrl", "image"]
166
+ for (key, value) in userInfo {
167
+ let keyStr = (key as? String) ?? "\(key)"
168
+ if keyStr == "aps" { continue }
54
169
 
55
- listener?(payload as NSDictionary)
170
+ if imageUrlKeys.contains(keyStr) {
171
+ if imageUrl == nil { imageUrl = value as? String }
172
+ } else if keyStr == "data", let nestedData = value as? [String: Any] {
173
+ for (nestedKey, nestedValue) in nestedData {
174
+ customData[nestedKey] = (nestedValue as? String) ?? String(describing: nestedValue)
175
+ }
176
+ } else {
177
+ customData[keyStr] = (value as? String) ?? String(describing: value)
178
+ }
56
179
  }
180
+
181
+ var iosMap: [String: Any] = [:]
182
+ if let badge = aps?["badge"] as? Int { iosMap["badge"] = badge }
183
+ if let sound = aps?["sound"] as? String { iosMap["sound"] = sound }
184
+ if let cat = aps?["category"] as? String { iosMap["category"] = cat }
185
+ if let tid = aps?["thread-id"] as? String { iosMap["threadId"] = tid }
186
+
187
+ var payload: [String: Any] = [
188
+ "title": title as Any,
189
+ "body": body as Any,
190
+ "imageUrl": imageUrl as Any,
191
+ "data": customData,
192
+ "android": NSNull(),
193
+ "ios": iosMap,
194
+ ]
195
+
196
+ return payload
57
197
  }
58
198
  }
@@ -0,0 +1,46 @@
1
+ import AppAmbitPushNotifications
2
+ import UserNotifications
3
+
4
+ /// RN-layer NSE base class. Subclass and override `appGroupIdentifier`
5
+ /// with the App Group shared between the main app and the NSE target.
6
+ open class AppAmbitRNNotificationService: AppAmbitNotificationService {
7
+
8
+ open var appGroupIdentifier: String { "" }
9
+
10
+ private static let storageKey = "com.appambit.pendingNotifications"
11
+ private static let maxStored = 50
12
+
13
+ open override func handlePayload(_ notification: AppAmbitNotification,
14
+ content: UNMutableNotificationContent) {
15
+ guard !appGroupIdentifier.isEmpty,
16
+ let defaults = UserDefaults(suiteName: appGroupIdentifier) else { return }
17
+ let entry = buildEntry(notification)
18
+ persist(entry, to: defaults)
19
+ }
20
+
21
+ private func buildEntry(_ notification: AppAmbitNotification) -> [String: Any] {
22
+ var data: [String: Any] = [:]
23
+ for (k, v) in notification.data {
24
+ if let key = k as? String,
25
+ JSONSerialization.isValidJSONObject([key: v]) {
26
+ data[key] = v
27
+ }
28
+ }
29
+ var entry: [String: Any] = [
30
+ "receivedAt": ISO8601DateFormatter().string(from: Date()),
31
+ "data": data,
32
+ ]
33
+ if let t = notification.title { entry["title"] = t }
34
+ if let b = notification.body { entry["body"] = b }
35
+ if let u = notification.imageUrl { entry["imageUrl"] = u }
36
+ return entry
37
+ }
38
+
39
+ private func persist(_ entry: [String: Any], to defaults: UserDefaults) {
40
+ var list = (defaults.array(forKey: Self.storageKey) as? [[String: Any]]) ?? []
41
+ list.insert(entry, at: 0)
42
+ if list.count > Self.maxStored { list = Array(list.prefix(Self.maxStored)) }
43
+ defaults.set(list, forKey: Self.storageKey)
44
+ defaults.synchronize()
45
+ }
46
+ }