appsonair-react-native-apppush 0.0.1-alpha

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 (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +540 -0
  3. package/android/build.gradle +177 -0
  4. package/android/gradle.properties +15 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/AndroidManifestNew.xml +2 -0
  7. package/android/src/main/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushModuleImpl.kt +767 -0
  8. package/android/src/main/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushPackage.kt +43 -0
  9. package/android/src/newarch/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushModule.kt +199 -0
  10. package/android/src/oldarch/java/com/appsonairreactnativeapppush/AppsonairReactNativeApppushModule.kt +249 -0
  11. package/appsonair-react-native-apppush.podspec +77 -0
  12. package/ios/AppsonairReactNativeApppush-Bridging-Header.h +12 -0
  13. package/ios/AppsonairReactNativeApppush.h +28 -0
  14. package/ios/AppsonairReactNativeApppush.mm +494 -0
  15. package/ios/AppsonairReactNativeApppushImpl.swift +594 -0
  16. package/lib/commonjs/NativeAppsonairApppush.js +48 -0
  17. package/lib/commonjs/NativeAppsonairApppush.js.map +1 -0
  18. package/lib/commonjs/index.js +686 -0
  19. package/lib/commonjs/index.js.map +1 -0
  20. package/lib/commonjs/types.js +2 -0
  21. package/lib/commonjs/types.js.map +1 -0
  22. package/lib/module/NativeAppsonairApppush.js +47 -0
  23. package/lib/module/NativeAppsonairApppush.js.map +1 -0
  24. package/lib/module/index.js +612 -0
  25. package/lib/module/index.js.map +1 -0
  26. package/lib/module/types.js +2 -0
  27. package/lib/module/types.js.map +1 -0
  28. package/lib/typescript/commonjs/package.json +1 -0
  29. package/lib/typescript/commonjs/src/NativeAppsonairApppush.d.ts +134 -0
  30. package/lib/typescript/commonjs/src/NativeAppsonairApppush.d.ts.map +1 -0
  31. package/lib/typescript/commonjs/src/index.d.ts +392 -0
  32. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  33. package/lib/typescript/commonjs/src/types.d.ts +221 -0
  34. package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
  35. package/lib/typescript/module/package.json +1 -0
  36. package/lib/typescript/module/src/NativeAppsonairApppush.d.ts +134 -0
  37. package/lib/typescript/module/src/NativeAppsonairApppush.d.ts.map +1 -0
  38. package/lib/typescript/module/src/index.d.ts +392 -0
  39. package/lib/typescript/module/src/index.d.ts.map +1 -0
  40. package/lib/typescript/module/src/types.d.ts +221 -0
  41. package/lib/typescript/module/src/types.d.ts.map +1 -0
  42. package/package.json +119 -0
  43. package/react-native.config.js +14 -0
  44. package/src/NativeAppsonairApppush.ts +188 -0
  45. package/src/index.tsx +745 -0
  46. package/src/types.ts +284 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Logicwind
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,540 @@
1
+ # appsonair-react-native-apppush
2
+
3
+ Push notifications for React Native
4
+
5
+ Works on both React Native architectures. New Arch gets a real Codegen
6
+ TurboModule, Old Arch gets a Bridge NativeModule — picked at build time, same JS
7
+ API either way. Nothing in your app code changes.
8
+
9
+ > [!WARNING]
10
+ > **Alpha release — not for production use.**
11
+ >
12
+ > `0.0.1-alpha` is an early preview, intended for evaluation, prototypes, and
13
+ > internal test builds. Do **not** ship it in a production app or one with a
14
+ > large user base.
15
+ >
16
+ > - The public API may change without notice and may not stay source-compatible —
17
+ > expect to update your integration between releases.
18
+ > - Breaking changes are not limited to major versions while the package is pre-1.0.
19
+ > - The native SDKs underneath are pre-release too (`0.0.2-alpha`), and carry the
20
+ > same caveats.
21
+ > - Not yet proven at scale; some behaviour is still unverified in real-world use.
22
+ >
23
+ > Pin this exact version rather than a version range, and re-test on every upgrade.
24
+
25
+ > [!NOTE]
26
+ > Both native SDKs resolve on their own — Android from JitPack, iOS from
27
+ > CocoaPods — so the only setup you need is in [Setup](#setup). The backend *is*
28
+ > live: the SDKs register the device and sync subscription state — tags,
29
+ > language, opt-in — to `/v1/subscriptions`.
30
+
31
+ ---
32
+
33
+ ## Install
34
+
35
+ > **Alpha.** Pin this exact version — the API may change between releases. See
36
+ > [the notice above](#appsonair-react-native-apppush) before adopting it.
37
+
38
+ ```sh
39
+ npm install appsonair-react-native-apppush@0.0.1-alpha
40
+ npx pod-install # iOS only
41
+ ```
42
+
43
+ `npm install appsonair-react-native-apppush` on its own will not find it: alpha
44
+ releases are published under the `alpha` dist-tag, not `latest`.
45
+
46
+ Autolinking does the rest — nothing to add to `MainApplication.kt` or `AppDelegate`.
47
+
48
+ | Requires | |
49
+ |---|---|
50
+ | React Native | 0.71+ |
51
+ | iOS | 15.0+ |
52
+ | Android | minSdk 24 |
53
+ | Kotlin | 1.9+ (whatever your RN version pins — see below) |
54
+
55
+ ---
56
+
57
+ ## Quick start
58
+
59
+ ```ts
60
+ import AppPushService from 'appsonair-react-native-apppush';
61
+
62
+ async function setupPush() {
63
+ await AppPushService.initialize({ debug: __DEV__ });
64
+
65
+ const granted = await AppPushService.requestPermission();
66
+ if (!granted) return;
67
+
68
+ // Resolves `null` until registration finishes — call it *after* permission.
69
+ const token = await AppPushService.getToken();
70
+ console.log('push token', token);
71
+
72
+ await AppPushService.login('user-42');
73
+ }
74
+ ```
75
+
76
+ Subscribe to events once near app start, and keep them for the app's lifetime:
77
+
78
+ ```ts
79
+ import { useEffect } from 'react';
80
+ import AppPushService from 'appsonair-react-native-apppush';
81
+
82
+ useEffect(() => {
83
+ const subs = [
84
+ AppPushService.onNotificationReceived(({ notification }) => {
85
+ console.log('foreground', notification.title, notification.data);
86
+ }),
87
+
88
+ AppPushService.onNotificationOpened(({ notification, actionId, url }) => {
89
+ if (url) navigate(url);
90
+ else if (actionId === 'reply') openReply(notification.id);
91
+ }),
92
+ ];
93
+
94
+ return () => subs.forEach((s) => s.remove());
95
+ }, []);
96
+ ```
97
+
98
+ Named exports work too, if you prefer them or want tree-shaking:
99
+
100
+ ```ts
101
+ import { initialize, getToken, onNotificationOpened, user } from 'appsonair-react-native-apppush';
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Setup
107
+
108
+ ### iOS
109
+
110
+ 1. **Signing & Capabilities → + Capability → Push Notifications.**
111
+ 2. Add **Background Modes → Remote notifications** if you use silent push.
112
+ 3. Add your AppsOnAir app id to `Info.plist`:
113
+
114
+ ```xml
115
+ <key>AppsonairAppId</key>
116
+ <string>your-app-id</string>
117
+ ```
118
+
119
+ <details>
120
+ <summary><b>App Groups</b> — needed for delivery receipts from a Notification Service Extension</summary>
121
+
122
+ iOS resolves the group itself: an `AppsOnAirAppGroup` string in
123
+ `Info.plist`, else the convention `group.<your-bundle-id>.appsonair`.
124
+
125
+ Enable that group on **both** the app target and your Notification Service
126
+ Extension — otherwise the extension's delivery receipts degrade silently.
127
+ </details>
128
+
129
+ <details>
130
+ <summary><b>Build fails with <code>__swift_FORCE_LOAD_$_swiftCompatibility56</code></b> — required if your app target has no Swift file</summary>
131
+
132
+ This wrapper and the SDKs beneath it are Swift static libraries. Xcode only adds
133
+ the Swift runtime search paths to a target that *contains* Swift, so a pure
134
+ Objective-C app (`AppDelegate.mm`, `main.m`, nothing else) fails to link:
135
+
136
+ ```
137
+ Undefined symbols: __swift_FORCE_LOAD_$_swiftCompatibility56
138
+ referenced from libAppsOnAir-Core.a, libAppsOnAir-AppPush.a,
139
+ libReachabilitySwift.a, libappsonair-react-native-apppush.a
140
+ ```
141
+
142
+ **Simplest fix** — add any empty `.swift` file to your app target and let Xcode
143
+ create the bridging header it offers. The file can stay empty.
144
+
145
+ **Or** add the paths in your Podfile's `post_install` (what `example/ios/Podfile` does):
146
+
147
+ ```ruby
148
+ installer.aggregate_targets.each do |aggregate_target|
149
+ aggregate_target.user_project.native_targets.each do |target|
150
+ target.build_configurations.each do |build_config|
151
+ build_config.build_settings['LIBRARY_SEARCH_PATHS'] = [
152
+ '$(inherited)',
153
+ # DT_TOOLCHAIN_DIR, not TOOLCHAIN_DIR -- the latter resolves to the
154
+ # Metal toolchain under Xcode 26 and the path does not exist.
155
+ '$(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)',
156
+ '/usr/lib/swift'
157
+ ]
158
+ end
159
+ end
160
+ aggregate_target.user_project.save
161
+ end
162
+ ```
163
+
164
+ This bites on Old Arch first. New Arch builds often link anyway because other
165
+ Swift-bearing pods pull the paths in — that's luck, not design. Apply it regardless.
166
+ </details>
167
+
168
+ <details>
169
+ <summary><b>Background sync</b> — extra <code>Info.plist</code> key</summary>
170
+
171
+ ```xml
172
+ <key>BGTaskSchedulerPermittedIdentifiers</key>
173
+ <array><string>com.appsonair.push.background-sync</string></array>
174
+ ```
175
+ </details>
176
+
177
+ <details>
178
+ <summary><b><code>AppsOnAir-Core</code> is pinned to <code>>= 1.2.3</code></b> — only matters if your Podfile.lock already carries an older one</summary>
179
+
180
+ This package declares `AppsOnAir-Core >= 1.2.3` alongside the Push SDK itself.
181
+ The floor is deliberate: `AppsOnAir-AppPush.podspec` depends on
182
+ `AppsOnAir-Core` with no constraint, but `AppsOnAirDeviceInfo` calls
183
+ `AppsOnAirCoreServices.getDeviceMetadata()`, which only exists in 1.2.x.
184
+
185
+ A fresh install resolves the newest Core and never notices. The case this covers
186
+ is an app that already pins an older Core in its `Podfile.lock` — likely if you
187
+ also use AppLink, AppSync or AppRemark — where an unconstrained dependency stays
188
+ satisfied by 1.1.1 and the Push SDK then fails to compile.
189
+
190
+ If CocoaPods reports a conflict with a Core version another pod requires:
191
+
192
+ ```sh
193
+ pod update AppsOnAir-Core
194
+ ```
195
+
196
+ The pin goes away once the upstream podspec carries its own floor.
197
+ </details>
198
+
199
+ <details>
200
+ <summary><b>Pointing at a local iOS SDK checkout</b> — for SDK development, or if the pod hasn't propagated yet</summary>
201
+
202
+ This package depends on `AppsOnAir-AppPush` `0.0.2-alpha` from the CocoaPods
203
+ trunk, so `npx pod-install` normally needs no help. To build against a local
204
+ checkout instead, declare it in your app's Podfile — a `:path` declaration wins
205
+ over this package's dependency line:
206
+
207
+ ```ruby
208
+ # ios/Podfile
209
+ pod 'AppsOnAir-AppPush', :path => '../../appsonair-ios-push-notification'
210
+ ```
211
+
212
+ If `pod install` can't find the pod at all, refresh your spec repo first with
213
+ `pod repo update`.
214
+ </details>
215
+
216
+ ### Android
217
+
218
+ The native SDK is on JitPack, and Firebase is required.
219
+
220
+ ```groovy
221
+ // android/settings.gradle
222
+ dependencyResolutionManagement {
223
+ repositories {
224
+ google()
225
+ mavenCentral()
226
+ maven { url 'https://jitpack.io' } // AppsOnAir Push SDK + AppsOnAir Core
227
+ }
228
+ }
229
+ ```
230
+
231
+ Add the Google Services plugin and your `google-services.json` as you would for
232
+ any FCM app. `POST_NOTIFICATIONS` (Android 13+) is requested for you by
233
+ `requestPermission()`.
234
+
235
+ <details>
236
+ <summary><b>Kotlin version</b> — why this package pins the stdlib, and what to do if your app still fails to compile</summary>
237
+
238
+ The published native SDK (`0.0.2-alpha`) is compiled with **Kotlin 2.2.10** and
239
+ sets no `languageVersion` floor, so its classes carry metadata `2.2.0`. No React
240
+ Native release ships a Kotlin compiler that can read that — 0.76 pins 1.9.24,
241
+ 0.77–0.78 pin 2.0.21, 0.79–0.81 pin 2.1.x — and raising your app's Kotlin to 2.2
242
+ is not a fix either: KGP 2.2 breaks React Native's own Gradle plugin with
243
+ `Found interface KotlinTopLevelExtension, but class was expected`.
244
+
245
+ This package works around it in `android/build.gradle` so you don't have to:
246
+ `-Xskip-metadata-version-check` lets this module read the SDK's classes, and a
247
+ `strictly` pin holds `kotlin-stdlib` at your app's Kotlin version — without that
248
+ second half the SDK drags stdlib 2.2.10 onto **your app's** compile classpath and
249
+ your own Kotlin stops compiling.
250
+
251
+ If your app still fails with `Module was compiled with an incompatible version of
252
+ Kotlin`, something else on your classpath is pulling the newer stdlib back in.
253
+ Check with:
254
+
255
+ ```sh
256
+ cd android && ./gradlew :app:dependencies --configuration debugCompileClasspath | grep kotlin-stdlib
257
+ ```
258
+
259
+ Both workarounds are temporary; they go away when the SDK republishes with an
260
+ older language version.
261
+ </details>
262
+
263
+ <details>
264
+ <summary><b>Overriding the Push SDK coordinate</b> — for a local build or a private repo</summary>
265
+
266
+ This package pins the released SDK. To point at something else, set the coordinate
267
+ from your app's `android/gradle.properties` — no need to patch this package:
268
+
269
+ ```properties
270
+ # The default.
271
+ AppsonairReactNativeApppush_pushSdkCoordinate=com.github.apps-on-air:appsonair-android-push-notification:0.0.2-alpha
272
+ ```
273
+ </details>
274
+
275
+ ---
276
+
277
+ ## Recipes
278
+
279
+ **Ask for permission at the right moment, not on launch**
280
+
281
+ ```ts
282
+ if (await AppPushService.getPermission()) return; // already granted, don't re-prompt
283
+
284
+ // `fallbackToSettings` sends the user to the OS settings screen when they've
285
+ // already permanently denied — without it the prompt just fails silently.
286
+ const granted = await AppPushService.requestPermission({ fallbackToSettings: true });
287
+ ```
288
+
289
+ **Identify a user, and tag them for targeting**
290
+
291
+ ```ts
292
+ await AppPushService.login('user-42');
293
+ await AppPushService.user.addTags({ plan: 'pro', locale: 'en-IN' });
294
+ await AppPushService.user.addEmail('someone@example.com');
295
+
296
+ // On sign-out
297
+ await AppPushService.logout();
298
+ ```
299
+
300
+ **Deep link from a notification tap**
301
+
302
+ ```ts
303
+ AppPushService.onNotificationOpened(({ notification, actionId, url }) => {
304
+ if (actionId) return handleAction(actionId, notification);
305
+ if (url) return Linking.openURL(url);
306
+ navigateToInbox();
307
+ });
308
+ ```
309
+
310
+ **Suppress a foreground notification** (Android only — see [Platform differences](#platform-differences))
311
+
312
+ ```ts
313
+ AppPushService.onNotificationWillDisplay(({ notification, preventDefault }) => {
314
+ // Must be called synchronously — native is holding the notification.
315
+ if (notification.data.screen === currentScreen) preventDefault();
316
+ });
317
+ ```
318
+
319
+ **Keep the badge tidy**
320
+
321
+ ```ts
322
+ await AppPushService.badge.set(unreadCount);
323
+ await AppPushService.badge.clear();
324
+ await AppPushService.badge.setAutoClearOnForeground(true); // iOS only
325
+ ```
326
+
327
+ **Forward the token to your own backend**
328
+
329
+ There's no `onTokenUpdated` event today, so poll for it after permission resolves:
330
+
331
+ ```ts
332
+ const token = await AppPushService.getToken();
333
+ if (token) await api.registerDevice(token);
334
+ ```
335
+
336
+ ---
337
+
338
+ ## API
339
+
340
+ Everything returns a `Promise` (except `isInitialized()`, which is synchronous)
341
+ and rejects rather than throwing natively.
342
+
343
+ Calling anything before `initialize()` resolves rejects with `notInitialized` on
344
+ both platforms — without that guard the native Android SDK would throw
345
+ `IllegalStateException` and crash the app.
346
+
347
+ **Lifecycle**
348
+
349
+ | | |
350
+ |---|---|
351
+ | `initialize(config?)` | `{ debug?: boolean }`. Await once at app start. |
352
+ | `isInitialized()` | Synchronous `boolean`. |
353
+
354
+ **Identity**
355
+
356
+ | | |
357
+ |---|---|
358
+ | `login(externalId)` · `setUserId(externalId)` | Aliases. Rejects on an empty string. |
359
+ | `logout()` | Back to an anonymous subscription. |
360
+ | `getExternalId()` | `null` while anonymous. |
361
+ | `getDeviceId()` | AppsOnAir device id. |
362
+ | `getSubscriptionId()` | Backend-assigned; `null` until registered. |
363
+
364
+ **Token**
365
+
366
+ | | |
367
+ |---|---|
368
+ | `getToken()` · `getDeviceToken()` | Aliases. APNs hex on iOS, FCM token on Android. `null` before registration. |
369
+
370
+ **Permissions**
371
+
372
+ | | |
373
+ |---|---|
374
+ | `requestPermission({ fallbackToSettings? })` | Resolves the resulting grant state. Rejects with `noActivity` on Android if there's no foreground Activity. |
375
+ | `getPermission()` | Fresh read on both platforms. |
376
+ | `getPermissionStatus()` | Five states on iOS; **only `authorized` / `denied` on Android**. |
377
+ | `canRequestPermission()` | Semantics differ per platform — see [Platform differences](#platform-differences). |
378
+ | `registerForProvisionalAuthorization()` | iOS only. |
379
+
380
+ **`notifications`**
381
+
382
+ | | |
383
+ |---|---|
384
+ | `clearAll()` | |
385
+ | `remove(id)` | Payload `notification_id`. |
386
+ | `removeMany(ids)` | One native call on iOS; loops on Android. |
387
+ | `removeGroup(groupKey)` | Android only. |
388
+
389
+ **`badge`**
390
+
391
+ | | |
392
+ |---|---|
393
+ | `get()` · `set(n)` · `increment(delta?)` · `clear()` | Best-effort on Android — see [Platform differences](#platform-differences). |
394
+ | `setAutoClearOnForeground(enabled)` | iOS only. |
395
+
396
+ **`user`**
397
+
398
+ ```ts
399
+ user.getAppsOnAirId() user.getExternalId()
400
+ user.addTag(k, v) user.addTags(obj) user.getTags()
401
+ user.removeTag(k) user.removeTags(keys)
402
+ user.addAlias(l, id) user.addAliases(obj)
403
+ user.removeAlias(l) user.removeAliases(labels)
404
+ user.addEmail(addr) user.removeEmail(addr)
405
+ user.setLanguage(code) user.getLanguage()
406
+ user.getPushSubscription() // { id, token, optedIn }
407
+ user.optIn() user.optOut() user.getOptedIn()
408
+ ```
409
+
410
+ **`consent`** — `setRequired` / `getRequired` / `setGiven` / `getGiven`
411
+ (stored, but [not enforced yet](#platform-differences)).
412
+
413
+ **`debug`** — `setLogLevel(level)`, safe to call before `initialize()`.
414
+
415
+ ### Events
416
+
417
+ Each returns a `Subscription` with `.remove()`.
418
+
419
+ | Event | Payload |
420
+ |---|---|
421
+ | `onNotificationReceived` | `{ notification }` |
422
+ | `onNotificationOpened` | `{ notification, actionId, url }` |
423
+ | `onNotificationWillDisplay` | `{ notification, preventDefault() }` |
424
+ | `onPermissionChanged` | `{ granted }` |
425
+ | `onSubscriptionChanged` | `{ previous, current }` |
426
+ | `onUserStateChanged` | `{ current }` |
427
+ | `onTokenUpdated` | `{ token, environment }` — `environment` is `'sandbox'` / `'production'` on iOS, `null` on Android. |
428
+ | `onError` | `{ code, message }` — SDK-level failures that happen outside any call you made. |
429
+ | `onSilentNotification` | `{ data }` — iOS sends `content-available`, Android needs a `silent: "true"` data key. |
430
+ | `onInstallationIdUpdated` | `{ id }` — Firebase Installation ID. **Android only.** |
431
+
432
+ Subscribe to `onTokenUpdated` if your backend stores the device token — it is
433
+ the only way to hear about a mid-session rotation, which `getToken()` at startup
434
+ will miss. Events raised before the first subscriber exists are dropped rather
435
+ than queued, so read the corresponding getter once after subscribing if you also
436
+ need the current value.
437
+
438
+ On the wire these are `AppsonairPush:onNotificationReceived`,
439
+ `AppsonairPush:onNotificationOpened` and so on — both native bridges emit that
440
+ prefix. You only need the raw names if you subscribe through
441
+ `NativeEventEmitter` yourself instead of using the helpers above.
442
+
443
+ ## Platform differences
444
+
445
+ Not wrapper bugs — places where the two native SDKs genuinely behave differently
446
+ and no bridge can honestly hide it. Each cites its row in
447
+ `CROSS_PLATFORM_PARITY.md` in the Android SDK repo.
448
+
449
+ | Area | Behaviour |
450
+ |---|---|
451
+ | **`preventDefault()`** (F7) | **Android only.** Android calls foreground listeners on FCM's background thread, so the bridge can wait for your handler. iOS returns presentation options synchronously — the notification is already on screen by the time JS runs. The event fires on both. |
452
+ | **`canRequestPermission()`** (E2) | iOS returns `true` only before the user has ever been asked. **Android returns `true` whenever permission isn't granted, including after a permanent denial.** Don't use it to decide whether to show a pre-prompt cross-platform. |
453
+ | **`user.addEmail()`** (A5) | Android persists emails but never reads them back on restart, so they're lost on relaunch. Needs an SDK fix. |
454
+ | **Badges** (G1, G3) | iOS uses the OS API. Android uses launcher broadcasts that **silently do nothing outside Samsung / MIUI / ASUS**, and `badge.get()` returns the SDK's own persisted count, which can drift from what's on screen. |
455
+ | **`user.getTags()`** | Both are backend-backed, but refresh differently. **Android fetches on every call**, so it sees other devices' writes immediately and can reject on network failure. **iOS reads a local cache** the SDK refreshes on `initialize()`, on first registration, on `login()`, and on every tag write — so a tag set elsewhere mid-session lands on iOS only after one of those. Neither call fetches before the device has registered; both fall back to the local cache. |
456
+ | **`user.getOptedIn()`** | Android reads the stored value from `/subscriptions`; iOS computes the flag it would send, which also requires OS permission. Revoking permission in Settings flips iOS to `false` while Android still reports the server's value. Both differ from `getPushSubscription().optedIn`, which is purely local on both. |
457
+ | **Payload keys** (C1, F1) | `subtitle`, `attachments`, `badgeIncrement`, `campaignId`, `templateId`, `sentAt` are iOS-only; `sound` and `channelId` are Android-only. The wrapper resolves the rest to `null` / `[]`, so the object shape is always the same. |
458
+ | **Device language** (1.5) | Android reads it live; iOS snapshots it once at init, so a mid-session change is stale on iOS. |
459
+ | **Consent flags** (I5) | `consentRequired` / `consentGiven` round-trip but **neither SDK enforces them yet**. Not a compliance control. |
460
+ | **`notifications.remove()`** (F2) | iOS matches the request identifier exactly; Android hashes the id, so two ids could in principle collide. |
461
+
462
+ ---
463
+
464
+ ## Troubleshooting
465
+
466
+ **"doesn't seem to be linked"** — rebuild after installing (a Metro restart isn't
467
+ enough), run `npx pod-install`, and check you're not on Expo Go, which can't load
468
+ custom native modules.
469
+
470
+ **iOS token never arrives** — confirm the Push Notifications capability is on the
471
+ target and `AppsonairAppId` is in `Info.plist`. The SDK logs the reason:
472
+
473
+ ```ts
474
+ await AppPushService.debug.setLogLevel('debug'); // before initialize()
475
+ ```
476
+
477
+ **Android taps do nothing on warm start** — the wrapper registers RN's
478
+ `ActivityEventListener` and forwards `onNewIntent` itself, so this shouldn't
479
+ happen. If it does, confirm your `MainActivity` isn't consuming the intent before
480
+ React Native sees it.
481
+
482
+ ---
483
+
484
+ ## Example app
485
+
486
+ `example/` is a full RN app that exercises the public API and reports which
487
+ native path actually loaded, so you can run the same screen against both
488
+ architectures and compare.
489
+
490
+ ```sh
491
+ npm install # from the repo root -- installs the example workspace too
492
+
493
+ cd example
494
+ npm run android:new # newArchEnabled=true -> TurboModule
495
+ npm run android:old # newArchEnabled=false -> Bridge
496
+ npm run ios:new # RCT_NEW_ARCH_ENABLED=1 pod install, then run
497
+ npm run ios:old # RCT_NEW_ARCH_ENABLED=0 pod install, then run
498
+ ```
499
+
500
+ Architecture is a build-time choice on both platforms — a Metro reload won't
501
+ switch it, and Android needs `./gradlew clean` between switches because Gradle
502
+ caches the generated Codegen sources.
503
+
504
+ The example resolves the **published** native SDKs (`0.0.2-alpha`) the same way
505
+ your app does — JitPack on Android, CocoaPods on iOS. Building against sibling
506
+ checkouts of the SDKs instead is opt-in: `-PappsonairLocalSdk=true` on Android,
507
+ `APPSONAIR_LOCAL_SDK=1` on iOS. See `example/README.md`.
508
+
509
+ ---
510
+
511
+ ## Contributing
512
+
513
+ <details>
514
+ <summary><b>How both architectures are supported</b></summary>
515
+
516
+ - **One spec.** `src/NativeAppsonairApppush.ts` is the Codegen spec, and also the
517
+ only method list — the JS layer, both Android modules and the iOS bridge are
518
+ all checked against it.
519
+ - **Android** compiles `src/newarch` or `src/oldarch` depending on
520
+ `newArchEnabled`. Both declare the same class in the same package and differ
521
+ only in their base class (`NativeAppsonairApppushSpec` vs
522
+ `ReactContextBaseJavaModule`); all behaviour lives in the shared
523
+ `AppsonairReactNativeApppushModuleImpl`.
524
+ - **iOS** uses one `RCT_EXPORT_METHOD` body per method. Codegen derives its ObjC
525
+ selectors from the same JS names the macro does, so a single implementation
526
+ satisfies the `NativeAppsonairApppushSpec` protocol on New Arch and registers with
527
+ the bridge on Old. `#ifdef RCT_NEW_ARCH_ENABLED` covers only the adopted
528
+ protocol and `getTurboModule:`.
529
+
530
+ **Adding a method** means changing all four: the spec, both Android modules, and
531
+ the iOS `.mm` + Swift impl. Codegen catches a missing Android override and a
532
+ missing iOS selector at compile time — but the Old Architecture module isn't
533
+ checked, so that one's on you.
534
+ </details>
535
+
536
+ ---
537
+
538
+ ## License
539
+
540
+ MIT