@usergist/feedback-react-native 0.1.1 → 0.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/EXPO-ACCEPTANCE.md +57 -0
  3. package/EXPO.md +103 -0
  4. package/README.md +10 -2
  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/Push.d.ts.map +1 -1
  18. package/dist/Push.js +9 -0
  19. package/dist/Push.js.map +1 -1
  20. package/dist/UserGist.d.ts +1 -0
  21. package/dist/UserGist.d.ts.map +1 -1
  22. package/dist/UserGist.js +218 -79
  23. package/dist/UserGist.js.map +1 -1
  24. package/dist/expo/index.d.ts +68 -0
  25. package/dist/expo/index.d.ts.map +1 -0
  26. package/dist/expo/index.js +120 -0
  27. package/dist/expo/index.js.map +1 -0
  28. package/dist/internal/consent.d.ts.map +1 -1
  29. package/dist/internal/consent.js +4 -1
  30. package/dist/internal/consent.js.map +1 -1
  31. package/dist/internal/context.d.ts +1 -1
  32. package/dist/internal/context.d.ts.map +1 -1
  33. package/dist/internal/context.js +1 -1
  34. package/dist/internal/context.js.map +1 -1
  35. package/dist/internal/engine.d.ts.map +1 -1
  36. package/dist/internal/engine.js +6 -1
  37. package/dist/internal/engine.js.map +1 -1
  38. package/dist/internal/events.d.ts +1 -0
  39. package/dist/internal/events.d.ts.map +1 -1
  40. package/dist/internal/events.js +6 -0
  41. package/dist/internal/events.js.map +1 -1
  42. package/dist/internal/push-dedupe.d.ts +3 -0
  43. package/dist/internal/push-dedupe.d.ts.map +1 -0
  44. package/dist/internal/push-dedupe.js +33 -0
  45. package/dist/internal/push-dedupe.js.map +1 -0
  46. package/dist/internal/storage.d.ts +1 -0
  47. package/dist/internal/storage.d.ts.map +1 -1
  48. package/dist/internal/storage.js +1 -0
  49. package/dist/internal/storage.js.map +1 -1
  50. package/dist/internal/transport.d.ts +5 -1
  51. package/dist/internal/transport.d.ts.map +1 -1
  52. package/dist/internal/transport.js +2 -2
  53. package/dist/internal/transport.js.map +1 -1
  54. package/dist/native/events.d.ts +2 -0
  55. package/dist/native/events.d.ts.map +1 -1
  56. package/dist/native/events.js +2 -0
  57. package/dist/native/events.js.map +1 -1
  58. package/dist/native/push-adapter.d.ts +11 -0
  59. package/dist/native/push-adapter.d.ts.map +1 -0
  60. package/dist/native/push-adapter.js +10 -0
  61. package/dist/native/push-adapter.js.map +1 -0
  62. package/dist/native/push-bridge.d.ts +9 -0
  63. package/dist/native/push-bridge.d.ts.map +1 -1
  64. package/dist/native/push-bridge.js +39 -4
  65. package/dist/native/push-bridge.js.map +1 -1
  66. package/dist/ui/requests/RequestsHost.d.ts.map +1 -1
  67. package/dist/ui/requests/RequestsHost.js +8 -0
  68. package/dist/ui/requests/RequestsHost.js.map +1 -1
  69. package/expo-module.config.json +8 -0
  70. package/expo-support/UserGistExpoAppDelegateSubscriber.swift +34 -0
  71. package/expo-support/UserGistFeedbackExpo.podspec +17 -0
  72. package/ios/Extension/UserGistNotificationService.swift +52 -5
  73. package/ios/Shared/UserGistNotificationActions.swift +27 -0
  74. package/ios/Shared/UserGistPushState.swift +110 -0
  75. package/ios/UserGistPush.h +1 -6
  76. package/ios/UserGistPush.mm +18 -0
  77. package/ios/UserGistPushImpl.swift +46 -4
  78. package/ios/UserGistPushInterop.h +5 -0
  79. package/package.json +33 -7
  80. package/plugin/index.cjs +269 -0
  81. package/plugin/index.d.cts +14 -0
  82. package/plugin/templates/UserGistExpoMessagingService.kt +20 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0-beta.0
4
+
5
+ - Add Expo config plugin, iOS extension/EAS setup, and an optional notification adapter.
6
+ - Preserve existing Expo notification ownership and shared provider tokens.
7
+ - Add scoped native delivery-receipt retries, Expo onboarding, demo and consumer checks.
8
+ - Keep logout and consent changes from accepting stale request responses or disabling a newer push session.
9
+ - Expo support remains prerelease until signed-device acceptance is complete.
10
+
3
11
  All notable changes to `@usergist/feedback-react-native` are documented here.
4
12
  Releases use [Semantic Versioning](https://semver.org/).
5
13
 
@@ -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,16 @@
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).
7
15
 
8
16
  ## Install
9
17
 
@@ -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
@@ -0,0 +1,69 @@
1
+ package studio.usergist.feedback
2
+
3
+ import android.content.Context
4
+ import android.content.pm.PackageManager
5
+ import org.json.JSONObject
6
+ import java.net.HttpURLConnection
7
+ import java.net.URL
8
+ import java.text.SimpleDateFormat
9
+ import java.util.Date
10
+ import java.util.Locale
11
+ import java.util.TimeZone
12
+ import java.util.UUID
13
+ import java.util.concurrent.Executors
14
+
15
+ internal object UserGistPushState {
16
+ private val worker = Executors.newSingleThreadExecutor()
17
+ private fun prefs(context: Context) = context.getSharedPreferences("usergist_push_state", Context.MODE_PRIVATE)
18
+ fun isExpo(context: Context): Boolean = metadata(context).getBoolean("UserGistExpo", false)
19
+ fun mode(context: Context): String = metadata(context).getString("UserGistPushMode") ?: "automatic"
20
+ private fun metadata(context: Context) = context.packageManager.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA).metaData ?: android.os.Bundle()
21
+ fun enabled(context: Context): Boolean = !isExpo(context) || prefs(context).getBoolean("push", false)
22
+ fun anonymousId(context: Context): String = JSONObject(prefs(context).getString("state", "{}")!!).optString("anonymousId")
23
+ fun accepts(context: Context, scope: String?): Boolean = enabled(context) && (scope.isNullOrEmpty() || scope == anonymousId(context))
24
+ private fun receiptPrefix(state: JSONObject) = "receipt." + UUID.nameUUIDFromBytes((state.optString("writeKey") + ":" + state.optString("anonymousId")).toByteArray(Charsets.UTF_8)) + "."
25
+ @Synchronized fun configure(context: Context, state: JSONObject) {
26
+ check(prefs(context).edit().putString("state", state.toString()).putBoolean("push", state.optBoolean("push")).commit())
27
+ if (state.optBoolean("push")) retry(context)
28
+ }
29
+ fun disable(context: Context) { prefs(context).edit().putBoolean("push", false).commit() }
30
+ fun receipt(context: Context, kind: String, id: String) {
31
+ if (id.isEmpty() || !enabled(context)) return
32
+ val state = JSONObject(prefs(context).getString("state", "{}")!!)
33
+ if (state.optString("writeKey").isEmpty()) return
34
+ val now = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date())
35
+ val body = if (kind == "silent-ack") JSONObject().put("pingId", id).put("anonymousId", state.optString("anonymousId")).put("receivedAt", now)
36
+ else JSONObject().put("deliveryId", id).put("occurredAt", now)
37
+ val store = prefs(context)
38
+ val prefix = receiptPrefix(state)
39
+ val record = JSONObject().put("state", state).put("kind", kind).put("body", body)
40
+ val key = prefix + UUID.randomUUID()
41
+ if (store.edit().putString(key, record.toString()).commit()) worker.execute { send(context, key, record) }
42
+ }
43
+ fun retry(context: Context) {
44
+ worker.execute {
45
+ val prefix = receiptPrefix(JSONObject(prefs(context).getString("state", "{}")!!))
46
+ prefs(context).all.filterKeys { it.startsWith(prefix) }.entries.take(500).forEach { (key, value) ->
47
+ runCatching { send(context, key, JSONObject(value as String)) }
48
+ }
49
+ }
50
+ }
51
+ private fun send(context: Context, key: String, record: JSONObject) {
52
+ val store = prefs(context)
53
+ val current = JSONObject(store.getString("state", "{}")!!)
54
+ val state = record.getJSONObject("state")
55
+ if (!store.getBoolean("push", false) || current.optString("anonymousId") != state.optString("anonymousId") || current.optString("writeKey") != state.optString("writeKey")) return
56
+ var connection: HttpURLConnection? = null
57
+ try {
58
+ connection = URL(state.getString("apiUrl").trimEnd('/') + "/v1/sdk/push/" + record.getString("kind")).openConnection() as HttpURLConnection
59
+ connection.connectTimeout = 5000; connection.readTimeout = 5000
60
+ connection.instanceFollowRedirects = false
61
+ connection.requestMethod = "POST"; connection.doOutput = true
62
+ connection.setRequestProperty("Authorization", "Bearer " + state.getString("writeKey"))
63
+ connection.setRequestProperty("Content-Type", "application/json")
64
+ connection.outputStream.use { it.write(record.getJSONObject("body").toString().toByteArray(Charsets.UTF_8)) }
65
+ if (connection.responseCode in 200..299) store.edit().remove(key).commit()
66
+ } catch (_: Exception) { /* Retry after foregrounding; retain the original receipt scope. */ }
67
+ finally { connection?.disconnect() }
68
+ }
69
+ }
package/app.plugin.js ADDED
@@ -0,0 +1,2 @@
1
+ // Expo 56+ uses Node 20.19+; keep the native config entry separate from Metro.
2
+ export { default } from './plugin/index.cjs'
@@ -14,6 +14,16 @@ export interface EnablePushResult {
14
14
  }
15
15
  export type PushPermissionStatus = 'authorized' | 'provisional' | 'denied' | 'not_determined';
16
16
  export interface Spec extends TurboModule {
17
+ getPushConfiguration?(): Promise<{
18
+ mode?: string;
19
+ environment?: 'sandbox' | 'production';
20
+ }>;
21
+ configurePushState?(state: {
22
+ writeKey: string;
23
+ apiUrl: string;
24
+ anonymousId: string;
25
+ push: boolean;
26
+ }): Promise<void>;
17
27
  enablePush(options: {
18
28
  [key: string]: unknown;
19
29
  } | null): Promise<EnablePushResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"NativeUserGistPush.d.ts","sourceRoot":"","sources":["../src/NativeUserGistPush.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAG/C,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,6BAA6B,CAAC,EAAE,OAAO,CAAA;IAChD,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,GAAG,YAAY,CAAA;IAC/C,qFAAqF;IACrF,QAAQ,CAAC,oBAAoB,CAAC,EAAE,OAAO,CAAA;CACxC;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,MAAM,EAAE,YAAY,GAAG,aAAa,GAAG,QAAQ,GAAG,gBAAgB,CAAA;IAC3E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,KAAK,GAAG,SAAS,CAAA;IACpC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,MAAM,oBAAoB,GAC5B,YAAY,GACZ,aAAa,GACb,QAAQ,GACR,gBAAgB,CAAA;AAEpB,MAAM,WAAW,IAAK,SAAQ,WAAW;IACvC,UAAU,CAAC,OAAO,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,GAAG,IAAI,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAA;IACjF,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5B,uBAAuB,IAAI,OAAO,CAAC,oBAAoB,CAAC,CAAA;IACxD,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3C,sBAAsB,IAAI,OAAO,CAAC;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IACpE,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAClD,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxD,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5C,iBAAiB,CAAC,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE7D,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACpC,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrC;;AAED,wBAA4D"}
1
+ {"version":3,"file":"NativeUserGistPush.d.ts","sourceRoot":"","sources":["../src/NativeUserGistPush.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAG/C,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,6BAA6B,CAAC,EAAE,OAAO,CAAA;IAChD,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,GAAG,YAAY,CAAA;IAC/C,qFAAqF;IACrF,QAAQ,CAAC,oBAAoB,CAAC,EAAE,OAAO,CAAA;CACxC;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;IACzB,QAAQ,CAAC,MAAM,EAAE,YAAY,GAAG,aAAa,GAAG,QAAQ,GAAG,gBAAgB,CAAA;IAC3E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,QAAQ,EAAE,KAAK,GAAG,SAAS,CAAA;IACpC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,MAAM,oBAAoB,GAC5B,YAAY,GACZ,aAAa,GACb,QAAQ,GACR,gBAAgB,CAAA;AAEpB,MAAM,WAAW,IAAK,SAAQ,WAAW;IACvC,oBAAoB,CAAC,IAAI,OAAO,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,SAAS,GAAG,YAAY,CAAA;KAAE,CAAC,CAAA;IAC3F,kBAAkB,CAAC,CAAC,KAAK,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACnH,UAAU,CAAC,OAAO,EAAE;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,GAAG,IAAI,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAA;IACjF,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5B,uBAAuB,IAAI,OAAO,CAAC,oBAAoB,CAAC,CAAA;IACxD,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3C,sBAAsB,IAAI,OAAO,CAAC;QAAE,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IACpE,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAClD,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxD,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC5C,iBAAiB,CAAC,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAE7D,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAAA;IACpC,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrC;;AAED,wBAA4D"}
@@ -1 +1 @@
1
- {"version":3,"file":"NativeUserGistPush.js","sourceRoot":"","sources":["../src/NativeUserGistPush.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,4EAA4E;AAC5E,uEAAuE;AACvE,wEAAwE;AACxE,4EAA4E;AAC5E,yDAAyD;AAGzD,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AAsClD,eAAe,mBAAmB,CAAC,GAAG,CAAO,cAAc,CAAC,CAAA"}
1
+ {"version":3,"file":"NativeUserGistPush.js","sourceRoot":"","sources":["../src/NativeUserGistPush.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,4EAA4E;AAC5E,uEAAuE;AACvE,wEAAwE;AACxE,4EAA4E;AAC5E,yDAAyD;AAGzD,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AAwClD,eAAe,mBAAmB,CAAC,GAAG,CAAO,cAAc,CAAC,CAAA"}