appambit-push-notifications 0.3.1 → 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.
- package/AppAmbitSdkPushNotifications.podspec +14 -4
- package/README.md +184 -105
- package/android/build.gradle +21 -3
- package/android/src/main/AndroidManifest.xml +107 -1
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitContextHolder.kt +22 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitHeadlessService.kt +177 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitInitProvider.kt +73 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitMessagingService.kt +12 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitNotificationSerializer.kt +88 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitPayloadUtils.kt +59 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitPushEventEmitter.kt +100 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitRNServiceExtension.kt +75 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitRemoteMessageStore.kt +26 -0
- package/android/src/main/java/com/appambitpushnotifications/AppambitPushNotificationsModule.kt +377 -76
- package/ios/AppAmbitNotificationSwizzler.m +290 -0
- package/ios/AppAmbitPushWrapper.swift +165 -25
- package/ios/AppAmbitRNNotificationService.swift +46 -0
- package/ios/AppambitPushNotifications.mm +264 -10
- package/lib/module/NativeAppambitPushNotifications.js.map +1 -1
- package/lib/module/index.js +46 -10
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeAppambitPushNotifications.d.ts +2 -1
- package/lib/typescript/src/NativeAppambitPushNotifications.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +32 -6
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeAppambitPushNotifications.ts +7 -1
- 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(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
notificationData["subtitle"] = content.subtitle
|
|
37
|
-
}
|
|
108
|
+
public static var pendingBackgroundPayloads: [[String: Any]] = []
|
|
38
109
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
+
}
|