@usergist/feedback-react-native 0.1.2 → 0.2.0-beta.1

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 (116) hide show
  1. package/CHANGELOG.md +11 -4
  2. package/EXPO-ACCEPTANCE.md +57 -0
  3. package/EXPO.md +103 -0
  4. package/README.md +69 -11
  5. package/UserGistFeedback.podspec +4 -1
  6. package/UserGistFeedbackExtension.podspec +1 -1
  7. package/android/build.gradle +3 -1
  8. package/android/src/main/AndroidManifest.xml +1 -0
  9. package/android/src/main/java/studio/usergist/feedback/UserGistFirebaseMessagingService.kt +78 -12
  10. package/android/src/main/java/studio/usergist/feedback/UserGistNotificationDismissReceiver.kt +17 -0
  11. package/android/src/main/java/studio/usergist/feedback/UserGistPushModule.kt +46 -5
  12. package/android/src/main/java/studio/usergist/feedback/UserGistPushState.kt +69 -0
  13. package/app.plugin.js +2 -0
  14. package/dist/NativeUserGistPush.d.ts +10 -0
  15. package/dist/NativeUserGistPush.d.ts.map +1 -1
  16. package/dist/NativeUserGistPush.js.map +1 -1
  17. package/dist/Provider.d.ts.map +1 -1
  18. package/dist/Provider.js +28 -17
  19. package/dist/Provider.js.map +1 -1
  20. package/dist/Push.d.ts +2 -0
  21. package/dist/Push.d.ts.map +1 -1
  22. package/dist/Push.js +50 -2
  23. package/dist/Push.js.map +1 -1
  24. package/dist/UserGist.d.ts +9 -4
  25. package/dist/UserGist.d.ts.map +1 -1
  26. package/dist/UserGist.js +492 -130
  27. package/dist/UserGist.js.map +1 -1
  28. package/dist/expo/index.d.ts +68 -0
  29. package/dist/expo/index.d.ts.map +1 -0
  30. package/dist/expo/index.js +120 -0
  31. package/dist/expo/index.js.map +1 -0
  32. package/dist/internal/consent.d.ts.map +1 -1
  33. package/dist/internal/consent.js +4 -1
  34. package/dist/internal/consent.js.map +1 -1
  35. package/dist/internal/context.d.ts +1 -1
  36. package/dist/internal/context.d.ts.map +1 -1
  37. package/dist/internal/context.js +1 -1
  38. package/dist/internal/context.js.map +1 -1
  39. package/dist/internal/engine.d.ts +8 -2
  40. package/dist/internal/engine.d.ts.map +1 -1
  41. package/dist/internal/engine.js +189 -39
  42. package/dist/internal/engine.js.map +1 -1
  43. package/dist/internal/inapp-rules-cache.d.ts +1 -0
  44. package/dist/internal/inapp-rules-cache.d.ts.map +1 -1
  45. package/dist/internal/inapp-rules-cache.js +7 -0
  46. package/dist/internal/inapp-rules-cache.js.map +1 -1
  47. package/dist/internal/mutation-queue.d.ts +2 -2
  48. package/dist/internal/mutation-queue.d.ts.map +1 -1
  49. package/dist/internal/mutation-queue.js +32 -4
  50. package/dist/internal/mutation-queue.js.map +1 -1
  51. package/dist/internal/push-dedupe.d.ts +3 -0
  52. package/dist/internal/push-dedupe.d.ts.map +1 -0
  53. package/dist/internal/push-dedupe.js +33 -0
  54. package/dist/internal/push-dedupe.js.map +1 -0
  55. package/dist/internal/rules-cache.d.ts +1 -0
  56. package/dist/internal/rules-cache.d.ts.map +1 -1
  57. package/dist/internal/rules-cache.js +7 -0
  58. package/dist/internal/rules-cache.js.map +1 -1
  59. package/dist/internal/storage.d.ts +1 -0
  60. package/dist/internal/storage.d.ts.map +1 -1
  61. package/dist/internal/storage.js +1 -0
  62. package/dist/internal/storage.js.map +1 -1
  63. package/dist/internal/survey-invitations.d.ts +25 -0
  64. package/dist/internal/survey-invitations.d.ts.map +1 -0
  65. package/dist/internal/survey-invitations.js +54 -0
  66. package/dist/internal/survey-invitations.js.map +1 -0
  67. package/dist/internal/survey-matcher.d.ts +1 -0
  68. package/dist/internal/survey-matcher.d.ts.map +1 -1
  69. package/dist/internal/survey-matcher.js +3 -0
  70. package/dist/internal/survey-matcher.js.map +1 -1
  71. package/dist/internal/survey-rules-cache.d.ts +1 -0
  72. package/dist/internal/survey-rules-cache.d.ts.map +1 -1
  73. package/dist/internal/survey-rules-cache.js +7 -0
  74. package/dist/internal/survey-rules-cache.js.map +1 -1
  75. package/dist/internal/survey-store.d.ts +9 -2
  76. package/dist/internal/survey-store.d.ts.map +1 -1
  77. package/dist/internal/survey-store.js +37 -16
  78. package/dist/internal/survey-store.js.map +1 -1
  79. package/dist/internal/transport.d.ts +15 -3
  80. package/dist/internal/transport.d.ts.map +1 -1
  81. package/dist/internal/transport.js +16 -13
  82. package/dist/internal/transport.js.map +1 -1
  83. package/dist/internal/types.d.ts +2 -0
  84. package/dist/internal/types.d.ts.map +1 -1
  85. package/dist/native/events.d.ts +2 -0
  86. package/dist/native/events.d.ts.map +1 -1
  87. package/dist/native/events.js +2 -0
  88. package/dist/native/events.js.map +1 -1
  89. package/dist/native/push-adapter.d.ts +11 -0
  90. package/dist/native/push-adapter.d.ts.map +1 -0
  91. package/dist/native/push-adapter.js +10 -0
  92. package/dist/native/push-adapter.js.map +1 -0
  93. package/dist/native/push-bridge.d.ts +9 -0
  94. package/dist/native/push-bridge.d.ts.map +1 -1
  95. package/dist/native/push-bridge.js +39 -4
  96. package/dist/native/push-bridge.js.map +1 -1
  97. package/dist/ui/SurveyView.d.ts.map +1 -1
  98. package/dist/ui/SurveyView.js +5 -5
  99. package/dist/ui/SurveyView.js.map +1 -1
  100. package/dist/ui/requests/RequestsHost.d.ts.map +1 -1
  101. package/dist/ui/requests/RequestsHost.js +8 -0
  102. package/dist/ui/requests/RequestsHost.js.map +1 -1
  103. package/expo-module.config.json +8 -0
  104. package/expo-support/UserGistExpoAppDelegateSubscriber.swift +34 -0
  105. package/expo-support/UserGistFeedbackExpo.podspec +17 -0
  106. package/ios/Extension/UserGistNotificationService.swift +52 -5
  107. package/ios/Shared/UserGistNotificationActions.swift +27 -0
  108. package/ios/Shared/UserGistPushState.swift +110 -0
  109. package/ios/UserGistPush.h +1 -6
  110. package/ios/UserGistPush.mm +18 -0
  111. package/ios/UserGistPushImpl.swift +46 -4
  112. package/ios/UserGistPushInterop.h +5 -0
  113. package/package.json +33 -7
  114. package/plugin/index.cjs +269 -0
  115. package/plugin/index.d.cts +14 -0
  116. package/plugin/templates/UserGistExpoMessagingService.kt +20 -0
package/CHANGELOG.md CHANGED
@@ -1,11 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.2
3
+ ## 0.2.0-beta.1
4
4
 
5
- - Add `presentationPaused`, `pausePresentation()`, and `resumePresentation()` so analytics starts at launch while campaign UI waits for the loaded screen.
6
- - Drop queued UI after consent withdrawal, reset, or a changed user; recheck readiness after asynchronous survey preparation.
7
- - Preserve the stable native package setup. Expo remains on its separate prerelease channel.
5
+ - Add startup presentation readiness: initialize with `presentationPaused`, then call `resumePresentation()` after the loaded screen is ready.
6
+ - Keep analytics and networking running while campaign UI is paused; `pausePresentation()` can protect later host flows without dismissing active UI.
7
+ - Invalidate queued presentation work after consent revocation, reset, or identity changes, including requests prepared asynchronously.
8
8
 
9
+ ## 0.2.0-beta.0
10
+
11
+ - Add Expo config plugin, iOS extension/EAS setup, and an optional notification adapter.
12
+ - Preserve existing Expo notification ownership and shared provider tokens.
13
+ - Add scoped native delivery-receipt retries, Expo onboarding, demo and consumer checks.
14
+ - Keep logout and consent changes from accepting stale request responses or disabling a newer push session.
15
+ - Expo support remains prerelease until signed-device acceptance is complete.
9
16
 
10
17
  All notable changes to `@usergist/feedback-react-native` are documented here.
11
18
  Releases use [Semantic Versioning](https://semver.org/).
@@ -0,0 +1,57 @@
1
+ # Expo release evidence
2
+
3
+ Status: implementation under validation; stable release is blocked.
4
+
5
+ Record the exact npm archive SHA256, source commit, Expo/React Native versions,
6
+ build profile, and app identifiers alongside each result. Never record tokens
7
+ or provider private keys. A mocked provider or generated Firebase fixture does
8
+ not count as a live push test.
9
+
10
+ ## Automated gates
11
+
12
+ - Packed archive resolves every existing export, `/expo`, the Node config
13
+ plugin, Expo subscriber, Kotlin dispatcher, and notification extension files.
14
+ - Expo 56.0.21 and 57.0.20: automatic and expo-notifications mode, iOS and
15
+ Android, with the New Architecture and repeated non-destructive prebuild.
16
+ - Existing React Native typecheck, unit tests, codegen/package check, and builds.
17
+ - Dashboard/API validation, Expo platform mapping, AI snippets, and onboarding.
18
+
19
+ ## Signed-device gates (pending)
20
+
21
+ - Physical iPhone and Android: anonymous startup, identify, logout/account switch,
22
+ offline relaunch and eventual synchronization; no cross-user data or token binding.
23
+ - All reference demo feedback types, surveys, in-app messages, request board,
24
+ comments/votes/follows, targeting, branding, and analytics.
25
+ - UserGist push: foreground/background/terminated launch; token refresh, permission
26
+ denial and later recovery, channels, badge behavior, images, actions, JSON actions,
27
+ deep links, silent reachability and delivered/open evidence.
28
+ - Coexistence: interleave unrelated Expo and UserGist messages, including cold
29
+ starts; one presentation and handler invocation per event/action. Disable
30
+ UserGist and verify unrelated notifications still arrive.
31
+ - Turn off networking while a native receipt is recorded; restore it and verify
32
+ the original delivery timestamp is acknowledged once. Logout before retry and
33
+ verify that old work is never attributed to the new account.
34
+ - iOS development APNs signing and a distribution-signed build, including
35
+ extension embedding, provisioning, App Group persistence and rich delivery.
36
+
37
+ ## Promotion
38
+
39
+ Publish a prerelease with npm tag `next` for consumer acceptance after package
40
+ and native build gates pass. Keep stable `latest` unchanged until all signed
41
+ device gates above have evidence. Do not describe Expo support as production
42
+ validated while this document remains pending.
43
+
44
+ The public release workflow enforces this through
45
+ `tools/expo/check-release-evidence.mjs`. Supply the repository variable
46
+ `USERGIST_EXPO_RELEASE_EVIDENCE` as JSON with `version`, `archiveSha256`,
47
+ `sourceCommit`, `nativeBuildRunId`, and arrays `nativeBuilds`, `regressions`, `physicalDevices`.
48
+ Each entry has `name`, `passed: true`, and a durable HTTPS `evidenceUrl` to
49
+ the corresponding build or device report. The script lists the exact required
50
+ names. Prereleases require package, prebuild, ordinary React Native regression,
51
+ and all eight Expo native combinations; stable additionally requires signed
52
+ physical-device evidence. A missing variable blocks publication. CI builds one archive and passes that file
53
+ to every consumer; the publish job downloads it from `nativeBuildRunId` instead
54
+ of rebuilding it. The release tag must point to that run’s source commit.
55
+
56
+ For stable promotion, validate the final stable archive too: changing a
57
+ prerelease version changes the package bytes and invalidates its earlier hash.
package/EXPO.md ADDED
@@ -0,0 +1,103 @@
1
+ # Expo integration (0.2 prerelease)
2
+
3
+ Expo uses the same `UserGist`, `Push`, and `UserGistProvider` as React Native.
4
+ The supported build targets are Expo 56 and 57 on iOS and Android, with the New
5
+ Architecture enabled. Use a development client or production binary. Expo Go
6
+ cannot provide the native bridge or durable secure storage. Browser targets
7
+ use `@usergist/feedback-web` separately.
8
+
9
+ ## Base setup
10
+
11
+ ```sh
12
+ npx expo install @usergist/feedback-react-native \
13
+ @react-native-async-storage/async-storage react-native-safe-area-context
14
+ ```
15
+
16
+ Add `"@usergist/feedback-react-native"` to `expo.plugins` in app.json or
17
+ app.config.js. Mount `UserGistProvider` once around the existing application:
18
+ in Expo Router's `app/_layout.tsx` wrap `Slot`/`Stack`; otherwise wrap App's
19
+ existing navigation. Call `initAsync` once with the public write key.
20
+
21
+ Anonymous users work without login. For identified users, obtain a scoped
22
+ subject token from your authenticated backend and call `identifyAsync`.
23
+ Never put a server API token in an `EXPO_PUBLIC_` variable. Connect `setConsent`
24
+ to the host's consent decisions, and await `reset()` before changing accounts.
25
+ The consent screen in our demo is a test harness, not a customer requirement.
26
+
27
+ ## Push configuration
28
+
29
+ ```js
30
+ plugins: [
31
+ ['@usergist/feedback-react-native', {
32
+ push: {
33
+ mode: 'automatic',
34
+ ios: { apsEnvironment: 'development' },
35
+ // android: { notificationIcon: './assets/notification-icon.png',
36
+ // notificationColor: '#3366FF' },
37
+ },
38
+ }],
39
+ ]
40
+ ```
41
+
42
+ Set `ios.bundleIdentifier`, `android.package`, and `android.googleServicesFile`.
43
+ The Google JSON is Firebase's client configuration for that package ID, not
44
+ a service-account private key. Upload APNs/FCM sending credentials to UserGist.
45
+ Never include the .p8 or service-account key in an Expo config or application.
46
+
47
+ `apsEnvironment` must match signing: development provisioning uses
48
+ `development`; TestFlight/App Store uses `production`. This is independent of
49
+ the UserGist data environment. Runtime registration reads this plugin setting;
50
+ call `enablePush()` without an environment override to use it.
51
+
52
+ The plugin generates the notification service extension and App Group needed
53
+ for rich media and native iOS delivery receipts. Defaults are
54
+ `UserGistNotificationServiceExtension`, `<bundleId>.UserGistNotificationServiceExtension`,
55
+ and `group.<bundleId>.usergist`. Override these with `extensionTargetName`,
56
+ `extensionBundleIdentifier`, and `appGroupIdentifier` under `push.ios`.
57
+ EAS receives the extension metadata before provisioning. Verify the app and
58
+ extension profiles include the same App Group.
59
+
60
+ Omitting `push` keeps notification acquisition disabled. Initialization and
61
+ configuration never request permission. After the user opts in and consent
62
+ synchronizes, call `enablePush` from a user action. Check `result.granted` and
63
+ `result.error`; `native_module_unavailable` or `push_not_configured` describes
64
+ build configuration, not an OS denial. Restore opt-in only after checking
65
+ current OS permission. `disablePush()` disables UserGist without removing an
66
+ Expo app's shared APNs/FCM registration.
67
+
68
+ ## Existing expo-notifications
69
+
70
+ Keep its existing plugin and handlers; place its plugin before UserGist's and
71
+ select `push.mode: 'expo-notifications'`. Configure the adapter before init:
72
+
73
+ ```ts
74
+ import * as Notifications from 'expo-notifications'
75
+ import { configureExpoNotifications } from '@usergist/feedback-react-native/expo'
76
+ const dispose = configureExpoNotifications(Notifications)
77
+ ```
78
+
79
+ Call once in the app bootstrap, not on every screen mount. Cleanup removes
80
+ the adapter listeners without modifying host notification handlers. The helper
81
+ does not request permissions, grant consent, or register a user on import.
82
+ UserGist uses native device tokens from `getDevicePushTokenAsync`, never
83
+ ExpoPushToken values. The Android dispatcher routes UserGist payloads to our
84
+ native renderer and other messages to Expo. On iOS, Expo retains foreground
85
+ presentation policy and response handling. Your existing Router/deep-link
86
+ handlers remain responsible for navigation.
87
+
88
+ ## Rebuilds and conflicts
89
+
90
+ Native/plugin changes require rebuilding with `npx expo run:ios`,
91
+ `npx expo run:android`, or EAS Build. EAS Update cannot add a native module.
92
+ For manually running prebuild against existing directories, Expo 57 requires
93
+ `--no-clean` to preserve those directories; Expo 56 preserves them by default.
94
+
95
+ The plugin does not overwrite a foreign extension target or custom FCM service.
96
+ If one exists, preserve it and integrate the existing host-forwarded `Push`
97
+ API and extension base class following the React Native README. Do not enable
98
+ two FCM services. Automatic composition is supported for expo-notifications;
99
+ other SDKs require an explicit integration. Change ownership modes in a fresh
100
+ native fixture, or review the existing host configuration before migrating.
101
+
102
+ Use the Expo demo and `EXPO-ACCEPTANCE.md` for acceptance. Building or receiving
103
+ a JavaScript callback alone is not physical-device push validation.
package/README.md CHANGED
@@ -2,8 +2,41 @@
2
2
 
3
3
  Production userGist SDK for React Native and the behavioral reference for the
4
4
  iOS, Android, and Flutter packages. Use the dashboard's SDK setup flow to
5
- verify authentication and ingestion; push releases additionally require APNs
6
- or FCM configuration and physical-device validation.
5
+ verify authentication and ingestion. The userGist APNs and FCM paths have
6
+ passed end-to-end physical-device validation; each host app still needs its own
7
+ provider credentials, identifiers, native callback setup, and signed-device
8
+ acceptance test.
9
+
10
+ ## Expo
11
+
12
+ Expo 56/57 development and production build integration is being validated for
13
+ 0.2. Use the same package with its config plugin; see [EXPO.md](EXPO.md).
14
+ Expo Go cannot load the native bridge. See [release gates](EXPO-ACCEPTANCE.md).
15
+
16
+ ## Startup presentation readiness
17
+
18
+ Initialize with `presentationPaused` enabled at app launch. Analytics, consent,
19
+ identity, and networking continue while campaign UI waits. After the existing
20
+ startup loading and navigation have finished and the loaded screen is visible,
21
+ call `resumePresentation()`. Mount any required UserGist UI provider before that
22
+ callback. Readiness must work for both anonymous and identified users.
23
+
24
+ Call `pausePresentation()` before another flow that must not be interrupted.
25
+ Pausing does not dismiss an already visible SDK surface. Queued feedback,
26
+ surveys, and in-app messages are discarded if their consent is withdrawn or
27
+ the user changes, even if consent is granted again before resuming. Repeated
28
+ initialization keeps the first readiness setting; repeated resume calls do not
29
+ show the same queued work twice. The option defaults to false for existing
30
+ integrations, so upgrading alone does not enable startup deferral.
31
+
32
+ Do not resume from a splash screen, an app-root mount that still shows loading,
33
+ a disappearing screen, or a fixed timer. Use the host's existing completion
34
+ callback; the SDK cannot infer when arbitrary startup navigation has finished.
35
+
36
+ ```ts
37
+ // In the loaded screen’s existing startup/navigation completion callback:
38
+ UserGist.resumePresentation()
39
+ ```
7
40
 
8
41
  ## Install
9
42
 
@@ -65,6 +98,7 @@ AppRegistry.registerComponent('app', () => App)
65
98
  | `UserGist.init(config)` | Returns synchronously, then hydrates and establishes the anonymous session in the background. |
66
99
  | `await UserGist.initAsync(config, initialIdentity?)` | Hydrates and optionally binds a server-proven identity before lifecycle events begin; returns `synced`, `queued`, or `rejected`. |
67
100
  | `UserGist.identify(userId, props?, subjectToken)` | Links the anonymous installation using a customer-backend-minted subject token. |
101
+ | `await UserGist.setUserProperties(values, unsetKeys?)` | Saves flat properties for the current anonymous or identified user; returns `synced`, `queued`, or `rejected`. Requires analytics consent. |
68
102
  | `await UserGist.identifyAsync(userId, props?, subjectToken)` | Backward-compatible async identity API returning `synced`, `queued`, or `rejected`. |
69
103
  | `UserGist.track(name, props?)` | Enqueues a stable event id and immediately evaluates only server-authorized client-side campaigns; all other decisions remain server-authoritative. |
70
104
  | `await UserGist.setConsent({ analytics?, feedback?, push?, survey? })` | Persists and synchronizes the transition, refreshes targeting rules, then resolves with `true`; returns `false` when synchronization fails. |
@@ -102,6 +136,39 @@ UserGist.init(config)
102
136
  The adapter uses the same asynchronous string interface as AsyncStorage, so it
103
137
  can wrap the host application's Keychain/Keystore-backed storage.
104
138
 
139
+ ## Personalization and immediate engagement
140
+
141
+ Use `await UserGist.setUserProperties({ first_name: 'Ava', country: 'IL' })` for
142
+ current profile values and `UserGist.track('show_watched', { show_id: '00123',
143
+ show_title: 'Midnight Orbit', position_seconds: 1234 })` for activity. Unset a
144
+ property with `await UserGist.setUserProperties({}, ['first_name'])`. Sensitive
145
+ fields follow the app's privacy allow-list. Both anonymous and identified users
146
+ are supported; await reset before switching accounts.
147
+
148
+ The dashboard's field picker can use a saved property, one latest matching event,
149
+ or the triggering event. Reuse one activity source for related title/ID/position
150
+ fields. Whole-value JSON tokens preserve types; your JSON action handler owns
151
+ navigation. See the [personalization guide](../../apps/landing/src/app/docs/(content)/guides/personalize-messages/page.mdx)
152
+ for authoring, fallbacks, recipient preview and a complete movie example.
153
+
154
+ With the matching API release, known server-dependent engagement events flush
155
+ immediately and can receive authorized content in the ingest response. Cached
156
+ native feedback/in-app matching remains local. Selecting web plus mobile retains
157
+ server consent/frequency checks while avoiding deliberate batch and polling waits.
158
+
159
+ Coordinated surveys can carry a prepared attempt. Eligible repeatable, uncapped,
160
+ non-personalized surveys can start from a signed cached permission valid for ten
161
+ minutes; already-started sessions can upload for seven days. The SDK saves the
162
+ original content and answers before background synchronization. Persistent storage
163
+ is required for recovery after termination; completion retains its submission/retry
164
+ handling. With a custom `onInvite` handler, `openSurvey(surveyId)` retains the
165
+ invitation's authorized questions and triggering-event values. An invitation does
166
+ not start a session until opened; failed starts retry the same attempt ID. Pending
167
+ invitations are identity-bound and expire with the instruction (at most 24 hours);
168
+ reset or survey-consent withdrawal clears them. On-demand/link resume remains
169
+ available. New personalized surveys still need online resolution. See the
170
+ [delivery and rollout notes](../../infra/docs/immediate-engagement.md).
171
+
105
172
  ## Architecture
106
173
 
107
174
  The SDK is a singleton that orchestrates:
@@ -230,12 +297,3 @@ silent-ping cycle for this user (saves provider quota + battery).
230
297
  ## License
231
298
 
232
299
  MIT
233
-
234
- ## Startup readiness
235
-
236
- Initialize with `presentationPaused: true`. Keep `UserGistProvider` mounted, then
237
- call `UserGist.resumePresentation()` from the host's existing completion callback
238
- after startup loading and navigation finish. Do not resume from a splash screen,
239
- a root mount while loading, or a timer. Both anonymous and identified users work.
240
- `pausePresentation()` protects later host flows without dismissing active UI or
241
- stopping analytics. Revoked consent and user changes invalidate queued messages.
@@ -23,7 +23,10 @@ Pod::Spec.new do |s|
23
23
  # The RN bridge module: RCTBridgeModule, RCTEventEmitter, Swift impl.
24
24
  # Pulls in React-Core because it imports <React/...> headers.
25
25
  s.subspec 'Core' do |core|
26
- core.source_files = 'ios/*.{h,m,mm,swift}'
26
+ core.source_files = 'ios/*.{h,m,mm,swift}', 'ios/Shared/*.swift'
27
+ # This shim is internal to the Obj-C implementation. Exporting its React
28
+ # imports through our Swift umbrella can hide React types in host modules.
29
+ core.private_header_files = 'ios/UserGistPush.h'
27
30
  core.frameworks = 'UIKit', 'UserNotifications', 'Security'
28
31
  core.dependency 'React-Core'
29
32
  end
@@ -18,6 +18,6 @@ Pod::Spec.new do |s|
18
18
  s.source = { :git => 'https://github.com/Future-Picnic/usergist-js.git', :tag => "v#{s.version}" }
19
19
  s.requires_arc = true
20
20
  s.swift_version = '5.7'
21
- s.source_files = 'ios/Extension/*.swift'
21
+ s.source_files = 'ios/Extension/*.swift', 'ios/Shared/*.swift'
22
22
  s.frameworks = 'UserNotifications'
23
23
  end
@@ -85,7 +85,9 @@ dependencies {
85
85
  // plugin in their app/build.gradle (Google's hard requirement for FCM).
86
86
  // Use a floor version that's broadly compatible with current Firebase BoM
87
87
  // ranges; consumer's BoM (if any) will pick the actual version.
88
- implementation "com.google.firebase:firebase-messaging:23.4.1"
88
+ // The public native dispatcher accepts RemoteMessage, so host adapters need
89
+ // Firebase on their compile classpath as well as at runtime.
90
+ api "com.google.firebase:firebase-messaging:23.4.1"
89
91
 
90
92
  implementation "androidx.core:core-ktx:1.12.0"
91
93
  implementation "androidx.security:security-crypto:1.1.0-alpha06"
@@ -9,6 +9,7 @@
9
9
  <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
10
10
 
11
11
  <application>
12
+ <receiver android:name="studio.usergist.feedback.UserGistNotificationDismissReceiver" android:exported="false" />
12
13
  <!-- Library-supplied FirebaseMessagingService. Hosts that already own
13
14
  FCM delivery must set userGistFirebaseServiceEnabled=false and
14
15
  forward tokens/messages through the public Push API. -->
@@ -31,15 +31,30 @@ import org.json.JSONArray
31
31
  */
32
32
  class UserGistFirebaseMessagingService : FirebaseMessagingService() {
33
33
 
34
- override fun onNewToken(token: String) {
35
- super.onNewToken(token)
36
- UserGistPushEventBus.emitToken(token)
34
+ override fun onNewToken(token: String) = handleToken(token)
35
+ override fun onMessageReceived(message: RemoteMessage) = handleMessage(this, message)
36
+ companion object {
37
+ @JvmStatic fun handleToken(token: String) = UserGistPushEventBus.emitToken(token)
38
+ @JvmStatic fun handleMessage(context: Context, message: RemoteMessage) {
39
+ if (!UserGistPushState.enabled(context)) return
40
+ // FCM already invokes this on its worker thread. Keep the service alive
41
+ // until presentation completes instead of leaving detached work behind.
42
+ UserGistMessageHandler(context.applicationContext).receive(message)
43
+ }
37
44
  }
45
+ }
38
46
 
39
- override fun onMessageReceived(remoteMessage: RemoteMessage) {
40
- super.onMessageReceived(remoteMessage)
41
-
42
- val payload = normalize(remoteMessage)
47
+ private class UserGistMessageHandler(context: Context) : android.content.ContextWrapper(context) {
48
+ fun receive(remoteMessage: RemoteMessage) {
49
+ val recipient = remoteMessage.data["usergist_anonymous_id"] ?: UserGistPushState.anonymousId(this)
50
+ if (!UserGistPushState.accepts(this, recipient)) return
51
+ if (remoteMessage.data["usergist_silent"] == "1") {
52
+ UserGistPushState.receipt(this, "silent-ack", remoteMessage.data["usergist_ping_id"] ?: "")
53
+ return
54
+ }
55
+ if (!remoteMessage.data.containsKey("usergist_campaign_id")) return
56
+ UserGistPushState.receipt(this, "delivered", remoteMessage.data["usergist_delivery_id"] ?: "")
57
+ val payload = normalize(remoteMessage, recipient)
43
58
  UserGistPushEventBus.emitNotificationReceived(payload)
44
59
 
45
60
  // Surface a system notification when the user-visible content is set.
@@ -52,7 +67,7 @@ class UserGistFirebaseMessagingService : FirebaseMessagingService() {
52
67
  postSystemNotification(
53
68
  title = title,
54
69
  body = body,
55
- data = remoteMessage.data
70
+ data = remoteMessage.data + ("usergist_anonymous_id" to recipient)
56
71
  )
57
72
  }
58
73
  }
@@ -74,17 +89,22 @@ class UserGistFirebaseMessagingService : FirebaseMessagingService() {
74
89
  val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or
75
90
  (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
76
91
  val contentIntent = launchIntent?.let {
77
- PendingIntent.getActivity(this, 0, it, pendingFlags)
92
+ PendingIntent.getActivity(this, (data["usergist_delivery_id"] ?: data.toString()).hashCode(), it, pendingFlags)
78
93
  }
79
94
 
80
- val notifId = (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
95
+ val notifId = (data["usergist_delivery_id"] ?: data.toString()).hashCode()
81
96
  val builder = NotificationCompat.Builder(this, channelId)
82
- .setSmallIcon(applicationInfo.icon.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info)
97
+ .setSmallIcon(packageManager.getApplicationInfo(packageName, android.content.pm.PackageManager.GET_META_DATA).metaData?.getInt("UserGistNotificationIcon", 0)?.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info)
83
98
  .setContentTitle(title)
84
99
  .setContentText(body)
85
100
  .setAutoCancel(true)
86
101
  .setContentIntent(contentIntent)
87
102
  .setPriority(NotificationCompat.PRIORITY_DEFAULT)
103
+ data["usergist_badge"]?.let { raw ->
104
+ val badges = getSharedPreferences("usergist_push_badges", Context.MODE_PRIVATE)
105
+ val count = if (raw == "+1") badges.getInt("count", 0) + 1 else raw.toIntOrNull()
106
+ if (count != null && count >= 0) { builder.setNumber(count); badges.edit().putInt("count", count).apply() }
107
+ }
88
108
 
89
109
  val rawActions = data["usergist_actions"]
90
110
  if (!rawActions.isNullOrBlank() && launchIntent != null) {
@@ -107,8 +127,53 @@ class UserGistFirebaseMessagingService : FirebaseMessagingService() {
107
127
  }
108
128
  }
109
129
 
130
+ val color = packageManager.getApplicationInfo(packageName, android.content.pm.PackageManager.GET_META_DATA).metaData?.get("UserGistNotificationColor")
131
+ when (color) {
132
+ is Int -> builder.setColor(color)
133
+ is String -> runCatching { builder.setColor(android.graphics.Color.parseColor(color)) }
134
+ }
135
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && data["usergist_sound"] != "none") {
136
+ val sound = data["usergist_sound"]
137
+ val custom = sound?.takeIf { it != "default" }?.let { resources.getIdentifier(it.substringBeforeLast('.'), "raw", packageName) } ?: 0
138
+ builder.setSound(if (custom != 0) android.net.Uri.parse("android.resource://$packageName/$custom") else android.media.RingtoneManager.getDefaultUri(android.media.RingtoneManager.TYPE_NOTIFICATION))
139
+ }
140
+ data["usergist_image_url"]?.let { raw ->
141
+ var connection: java.net.HttpURLConnection? = null
142
+ try {
143
+ val url = java.net.URL(raw)
144
+ if (url.protocol == "https") {
145
+ connection = url.openConnection() as java.net.HttpURLConnection
146
+ connection.connectTimeout = 4000; connection.readTimeout = 4000
147
+ connection.instanceFollowRedirects = false
148
+ if (connection.responseCode in 200..299) {
149
+ val bytes = connection.inputStream.use { stream ->
150
+ val out = java.io.ByteArrayOutputStream(); val buffer = ByteArray(8192)
151
+ while (out.size() <= 5 * 1024 * 1024) { val count = stream.read(buffer); if (count < 0) break; out.write(buffer, 0, count) }
152
+ out.toByteArray()
153
+ }
154
+ if (bytes.size <= 5 * 1024 * 1024) {
155
+ val bounds = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true }
156
+ android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
157
+ val decode = android.graphics.BitmapFactory.Options().apply { inSampleSize = (maxOf(bounds.outWidth, bounds.outHeight) / 1024).coerceAtLeast(1) }
158
+ android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decode)?.let { builder.setStyle(NotificationCompat.BigPictureStyle().bigPicture(it)) }
159
+ }
160
+ }
161
+ }
162
+ } catch (_: Exception) { /* Text notification still displays. */ }
163
+ finally { connection?.disconnect() }
164
+ }
165
+ val dismiss = Intent(this, UserGistNotificationDismissReceiver::class.java).apply {
166
+ data.forEach { (k, v) -> putExtra(k, v) }
167
+ }
168
+ builder.setDeleteIntent(PendingIntent.getBroadcast(this, notifId, dismiss, pendingFlags))
110
169
  val nm = NotificationManagerCompat.from(this)
170
+ if (!UserGistPushState.accepts(this, data["usergist_anonymous_id"]) || !nm.areNotificationsEnabled()) return
171
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm.getNotificationChannel(channelId)?.importance == NotificationManager.IMPORTANCE_NONE) return
111
172
  nm.notify(notifId, builder.build())
173
+ UserGistPushState.receipt(this, "displayed", data["usergist_delivery_id"] ?: "")
174
+ val displayed = Arguments.createMap()
175
+ displayed.putMap("data", Arguments.createMap().apply { data.forEach { (k, v) -> putString(k, v) } })
176
+ UserGistPushEventBus.emitNotificationDisplayed(displayed)
112
177
  }
113
178
 
114
179
  private fun ensureChannel(ctx: Context): String {
@@ -133,7 +198,7 @@ class UserGistFirebaseMessagingService : FirebaseMessagingService() {
133
198
  return if (nm.getNotificationChannel(requested) != null) requested else ensureChannel(ctx)
134
199
  }
135
200
 
136
- private fun normalize(msg: RemoteMessage): WritableMap {
201
+ private fun normalize(msg: RemoteMessage, recipient: String): WritableMap {
137
202
  val out = Arguments.createMap()
138
203
  msg.notification?.title?.let { out.putString("title", it) }
139
204
  msg.notification?.body?.let { out.putString("body", it) }
@@ -141,6 +206,7 @@ class UserGistFirebaseMessagingService : FirebaseMessagingService() {
141
206
  for ((k, v) in msg.data) {
142
207
  data.putString(k, v)
143
208
  }
209
+ data.putString("usergist_anonymous_id", recipient)
144
210
  out.putMap("data", data)
145
211
  val deliveryId = msg.data["delivery_id"] ?: msg.data["usergist_delivery_id"]
146
212
  if (deliveryId != null) out.putString("deliveryId", deliveryId)
@@ -0,0 +1,17 @@
1
+ package studio.usergist.feedback
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+
7
+ class UserGistNotificationDismissReceiver : BroadcastReceiver() {
8
+ override fun onReceive(context: Context, intent: Intent) {
9
+ if (!UserGistPushState.accepts(context, intent.getStringExtra("usergist_anonymous_id"))) return
10
+ UserGistPushState.receipt(context, "dismissed", intent.getStringExtra("usergist_delivery_id") ?: "")
11
+ val body = com.facebook.react.bridge.Arguments.createMap()
12
+ body.putMap("data", com.facebook.react.bridge.Arguments.createMap().apply {
13
+ intent.extras?.keySet()?.forEach { key -> intent.getStringExtra(key)?.let { putString(key, it) } }
14
+ })
15
+ UserGistPushEventBus.emitNotificationDismissed(body)
16
+ }
17
+ }
@@ -49,6 +49,12 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
49
49
  }
50
50
 
51
51
  override fun getName(): String = NAME
52
+ override fun invalidate() {
53
+ reactApplicationContext.removeLifecycleEventListener(this)
54
+ reactApplicationContext.removeActivityEventListener(this)
55
+ UserGistPushEventBus.detach(reactApplicationContext)
56
+ super.invalidate()
57
+ }
52
58
 
53
59
  private val securePreferences by lazy {
54
60
  val masterKey = MasterKey.Builder(reactApplicationContext)
@@ -64,7 +70,8 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
64
70
  }
65
71
 
66
72
  override fun onHostResume() {
67
- currentActivity?.intent?.let { consumeNotificationIntent(it) }
73
+ UserGistPushState.retry(reactApplicationContext)
74
+ reactApplicationContext.currentActivity?.intent?.let { consumeNotificationIntent(it) }
68
75
  }
69
76
 
70
77
  override fun onHostPause() = Unit
@@ -77,6 +84,7 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
77
84
 
78
85
  private fun consumeNotificationIntent(intent: Intent) {
79
86
  val extras = intent.extras ?: return
87
+ if (!UserGistPushState.accepts(reactApplicationContext, extras.getString("usergist_anonymous_id"))) return
80
88
  val keys = extras.keySet().filter { it.startsWith("usergist_") }
81
89
  if (keys.isEmpty()) return
82
90
  val body = Arguments.createMap()
@@ -99,8 +107,23 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
99
107
 
100
108
  // MARK: - Exported methods
101
109
 
110
+ @ReactMethod
111
+ fun getPushConfiguration(promise: Promise) {
112
+ promise.resolve(Arguments.createMap().apply { putString("mode", UserGistPushState.mode(reactApplicationContext)) })
113
+ }
114
+
115
+ @ReactMethod
116
+ fun configurePushState(state: ReadableMap, promise: Promise) {
117
+ runCatching { UserGistPushState.configure(reactApplicationContext, org.json.JSONObject(state.toHashMap())) }
118
+ .onSuccess { promise.resolve(null) }.onFailure { promise.reject("push_state_failed", it) }
119
+ }
120
+
102
121
  @ReactMethod
103
122
  fun enablePush(_options: ReadableMap?, promise: Promise) {
123
+ if (UserGistPushState.mode(reactApplicationContext) == "disabled") {
124
+ promise.resolve(buildResult(false, "not_determined", null, "push_not_configured"))
125
+ return
126
+ }
104
127
  // Android < 13: notification permission is install-time, no runtime
105
128
  // prompt. We can short-circuit straight to the token fetch.
106
129
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
@@ -112,7 +135,7 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
112
135
  fetchTokenAndResolve(promise, status = "authorized")
113
136
  return
114
137
  }
115
- val activity = currentActivity
138
+ val activity = reactApplicationContext.currentActivity
116
139
  if (activity == null) {
117
140
  // No activity: can't ask for permission. Resolve with current state.
118
141
  val granted = ActivityCompat.checkSelfPermission(
@@ -165,6 +188,8 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
165
188
 
166
189
  @ReactMethod
167
190
  fun disablePush(promise: Promise) {
191
+ UserGistPushState.disable(reactApplicationContext)
192
+ if (UserGistPushState.isExpo(reactApplicationContext)) { promise.resolve(null); return }
168
193
  FirebaseMessaging.getInstance().deleteToken()
169
194
  .addOnCompleteListener { promise.resolve(null) }
170
195
  }
@@ -233,10 +258,10 @@ class UserGistPushModule(reactContext: ReactApplicationContext) :
233
258
  // Required for RN's NativeEventEmitter API even though we emit from a
234
259
  // separate static bus — RN will warn otherwise.
235
260
  @ReactMethod
236
- fun addListener(_eventName: String?) { /* no-op */ }
261
+ fun addListener(_eventName: String?) { UserGistPushEventBus.addListener() }
237
262
 
238
263
  @ReactMethod
239
- fun removeListeners(_count: Double) { /* no-op */ }
264
+ fun removeListeners(_count: Double) { UserGistPushEventBus.removeListeners(_count.toInt()) }
240
265
 
241
266
  // MARK: - Internal
242
267
 
@@ -287,10 +312,12 @@ internal object UserGistPushEventBus {
287
312
  @Volatile private var reactContext: ReactApplicationContext? = null
288
313
  private val pending: MutableList<Pair<String, WritableMap>> = mutableListOf()
289
314
  private var initialNotification: WritableMap? = null
315
+ private var listenerCount = 0
290
316
 
291
317
  @Synchronized
292
318
  fun attach(ctx: ReactApplicationContext) {
293
319
  this.reactContext = ctx
320
+ if (listenerCount == 0) return
294
321
  val drained = pending.toList()
295
322
  pending.clear()
296
323
  for ((name, body) in drained) {
@@ -298,6 +325,20 @@ internal object UserGistPushEventBus {
298
325
  }
299
326
  }
300
327
 
328
+ @Synchronized
329
+ fun addListener() {
330
+ listenerCount++
331
+ // Drain after all JS subscriptions in this tick have been registered.
332
+ android.os.Handler(android.os.Looper.getMainLooper()).post {
333
+ synchronized(this) { if (listenerCount > 0) reactContext?.let { attach(it) } }
334
+ }
335
+ }
336
+ @Synchronized fun removeListeners(count: Int) { listenerCount = (listenerCount - count).coerceAtLeast(0) }
337
+ @Synchronized fun detach(ctx: ReactApplicationContext) { if (reactContext === ctx) { reactContext = null; listenerCount = 0 } }
338
+
339
+ fun emitNotificationDisplayed(body: WritableMap) = emit("UserGistPush:notificationDisplayed", body)
340
+ fun emitNotificationDismissed(body: WritableMap) = emit("UserGistPush:notificationDismissed", body)
341
+
301
342
  fun emitToken(token: String) {
302
343
  val map = Arguments.createMap()
303
344
  map.putString("token", token)
@@ -328,7 +369,7 @@ internal object UserGistPushEventBus {
328
369
  @Synchronized
329
370
  private fun emit(name: String, body: WritableMap) {
330
371
  val ctx = reactContext
331
- if (ctx == null || !ctx.hasActiveReactInstance()) {
372
+ if (ctx == null || !ctx.hasActiveReactInstance() || listenerCount == 0) {
332
373
  if (pending.size >= 100) pending.removeAt(0)
333
374
  pending.add(name to body)
334
375
  return