@dashx/react-native 1.4.0 → 1.4.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.
package/.DS_Store CHANGED
Binary file
package/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  All notable changes to `@dashx/react-native` are documented in this file. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/).
4
4
 
5
+ ## [1.4.1] — 2026-05-06
6
+
7
+ ### Required upgrade steps
8
+
9
+ 1. **Bump the dashx-ios pod tag to 1.5.1 in your `ios/Podfile`** — `pod install` will refuse the upgrade otherwise (the podspec floor is now `>= 1.5.1`):
10
+
11
+ ```ruby
12
+ pod 'DashX/SDK', :git => 'https://github.com/dashxhq/dashx-ios.git', :tag => '1.5.1'
13
+ ```
14
+
15
+ The floor is required because earlier dashx-ios versions either fall back to silent-push on the backend (pre-1.3.0, throttled by iOS) or omit `.timeSensitive` from the authorization request (pre-1.5.1, causing `interruption-level: time-sensitive` payloads to silently downgrade and stay filterable by Focus / Reduce Interruptions / Scheduled Summary on iOS 18 / 26).
16
+
17
+ 2. **Wire `fallbackToSettings: true` somewhere in your permission UI** to recover existing iOS users — fresh installs are auto-fixed by the upgrade, but users who already granted notification permission under the old SDK keep an `.authorized` state without the time-sensitive entitlement, and Apple's `requestAuthorization` is a one-shot grant that can't be silently upgraded. They have to flip the **Time Sensitive Notifications** toggle in iOS Settings themselves.
18
+
19
+ ```js
20
+ // From a "Notifications acting up?" affordance, or as a one-time
21
+ // post-upgrade prompt:
22
+ await DashX.requestNotificationPermission({ fallbackToSettings: true });
23
+ ```
24
+
25
+ When the user is already determined (granted or denied on iOS / permanently denied on Android), this opens the system notification settings page instead of firing a futile `requestAuthorization`. Without this step, your existing iOS users will keep experiencing the original "intermittent / second attempt works" symptom even after upgrading to 1.4.1.
26
+
27
+ ### Migration
28
+
29
+ - **Consumers who subclass `DashXReactNativeModuleImpl`** and override `requestNotificationPermission(promise:)` need to update the override signature to `requestNotificationPermission(fallbackToSettings: Boolean, promise: Promise)`. Otherwise Kotlin's override resolution silently drops to the impl's version and your custom logic stops running. Same applies to anyone subclassing the new-arch / old-arch `DashXReactNativeModule` wrappers. Most consumers don't subclass these — flagged here for the few that do.
30
+
31
+ ### Added
32
+
33
+ - **`DashX.requestNotificationPermission({ fallbackToSettings })`.** Optional flag, default `false`. When `true`, the SDK detects platform-specific dead-ends and opens the system notification-settings page instead of firing a futile prompt:
34
+ - **iOS** — already granted or denied (iOS no-ops `requestAuthorization` in that state).
35
+ - **Android** — `POST_NOTIFICATIONS` permanently denied (Android 13+, two-strikes rule). `requestPermissions()` auto-resolves DENIED without UI in that state.
36
+
37
+ Existing call sites (`DashX.requestNotificationPermission()` with no arguments) compile and behave unchanged.
38
+
39
+ ### Fixed
40
+
41
+ - **Foreground push duplicate-display race in `DashXNotificationHandler.handleRemoteNotification`.** Now short-circuits when the payload already carries `aps.alert`, letting iOS render the notification natively via NSE / `willPresent` instead of racing a duplicate `UNUserNotificationCenter.add(...)` against it. Without the short-circuit the local re-add could clobber the NSE-attached image or briefly flicker on screen.
42
+ - **Suspension race in the image-attached reconstruction path.** The `fetchCompletionHandler` now fires only after the async image download and the local `add()` both complete, with a 25-second budget under iOS's background-push limit. Previously the handler fired immediately while the download was still in flight, letting iOS suspend the process before the notification actually displayed.
43
+
5
44
  ## [1.4.0] — 2026-04-27
6
45
 
7
46
  ### Changed
@@ -21,10 +21,16 @@ Pod::Spec.new do |s|
21
21
  install_modules_dependencies(s)
22
22
 
23
23
  # DashX iOS SDK — consumers must provide the source in their Podfile:
24
- # pod 'DashX/SDK', :git => 'https://github.com/dashxhq/dashx-ios.git', :tag => '1.5.0'
24
+ # pod 'DashX/SDK', :git => 'https://github.com/dashxhq/dashx-ios.git', :tag => '1.5.1'
25
25
  # For local development:
26
26
  # pod 'DashX', :path => '../dashx-ios'
27
- s.dependency "DashX/SDK"
27
+ #
28
+ # Floor of 1.5.1 — earlier versions either fall back to silent-push on the
29
+ # backend (pre-1.3.0, which iOS throttles aggressively) or omit the
30
+ # `.timeSensitive` authorization option (pre-1.5.1, which causes
31
+ # `interruption-level: time-sensitive` payloads to silently downgrade to
32
+ # `active` on iOS 18 / 26 and stay subject to Focus / Summary filtering).
33
+ s.dependency "DashX/SDK", ">= 1.5.1"
28
34
 
29
35
  # Hard dependency so that `canImport(FirebaseMessaging)`
30
36
  # evaluates at this pod's compile time, where consumer-Podfile pods aren't
@@ -5,8 +5,10 @@ import android.app.Activity
5
5
  import android.app.NotificationChannel
6
6
  import android.app.NotificationManager
7
7
  import android.content.Context
8
+ import android.content.Intent
8
9
  import android.content.pm.PackageManager
9
10
  import android.os.Build
11
+ import android.provider.Settings
10
12
  import androidx.core.app.ActivityCompat
11
13
  import androidx.core.content.ContextCompat
12
14
  import com.dashx.rn.sdk.util.*
@@ -330,7 +332,7 @@ class DashXReactNativeModuleImpl(private val reactContext: ReactApplicationConte
330
332
  )
331
333
  }
332
334
 
333
- fun requestNotificationPermission(promise: Promise) {
335
+ fun requestNotificationPermission(fallbackToSettings: Boolean, promise: Promise) {
334
336
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
335
337
  promise.resolve(PERMISSION_AUTHORIZED)
336
338
  return
@@ -345,14 +347,36 @@ class DashXReactNativeModuleImpl(private val reactContext: ReactApplicationConte
345
347
  return
346
348
  }
347
349
 
348
- val activity = reactContext.currentActivity as? PermissionAwareActivity
350
+ val activity = reactContext.currentActivity
349
351
  if (activity == null) {
350
352
  promise.reject(E_UNSPECIFIED, "No current activity to request permission from")
351
353
  return
352
354
  }
353
355
 
354
- reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
355
- .edit()
356
+ // "Permanently denied" detection on Android 13+: we asked at least
357
+ // once before AND the system says we shouldn't show a rationale.
358
+ // In that state `requestPermissions()` auto-resolves DENIED without
359
+ // any UI, so re-prompting is futile — Settings is the only path.
360
+ val prefs = reactContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
361
+ val previouslyRequested = prefs.getBoolean(KEY_PERMISSION_REQUESTED, false)
362
+ val permanentlyDenied = previouslyRequested && !ActivityCompat.shouldShowRequestPermissionRationale(
363
+ activity,
364
+ Manifest.permission.POST_NOTIFICATIONS
365
+ )
366
+
367
+ if (fallbackToSettings && permanentlyDenied) {
368
+ openAppNotificationSettings()
369
+ promise.resolve(PERMISSION_DENIED)
370
+ return
371
+ }
372
+
373
+ val permAwareActivity = activity as? PermissionAwareActivity
374
+ if (permAwareActivity == null) {
375
+ promise.reject(E_UNSPECIFIED, "Activity does not support permission requests")
376
+ return
377
+ }
378
+
379
+ prefs.edit()
356
380
  .putBoolean(KEY_PERMISSION_REQUESTED, true)
357
381
  .apply()
358
382
 
@@ -363,13 +387,21 @@ class DashXReactNativeModuleImpl(private val reactContext: ReactApplicationConte
363
387
  true
364
388
  }
365
389
 
366
- activity.requestPermissions(
390
+ permAwareActivity.requestPermissions(
367
391
  arrayOf(Manifest.permission.POST_NOTIFICATIONS),
368
392
  NOTIFICATION_PERMISSION_REQUEST_CODE,
369
393
  listener
370
394
  )
371
395
  }
372
396
 
397
+ private fun openAppNotificationSettings() {
398
+ val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
399
+ putExtra(Settings.EXTRA_APP_PACKAGE, reactContext.packageName)
400
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
401
+ }
402
+ reactContext.startActivity(intent)
403
+ }
404
+
373
405
  fun getNotificationPermissionStatus(promise: Promise) {
374
406
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
375
407
  promise.resolve(PERMISSION_AUTHORIZED)
@@ -69,8 +69,8 @@ class DashXReactNativeModule(reactContext: ReactApplicationContext) :
69
69
 
70
70
  override fun fetchAsset(assetId: String, promise: Promise) = impl.fetchAsset(assetId, promise)
71
71
 
72
- override fun requestNotificationPermission(promise: Promise) =
73
- impl.requestNotificationPermission(promise)
72
+ override fun requestNotificationPermission(fallbackToSettings: Boolean, promise: Promise) =
73
+ impl.requestNotificationPermission(fallbackToSettings, promise)
74
74
 
75
75
  override fun getNotificationPermissionStatus(promise: Promise) =
76
76
  impl.getNotificationPermissionStatus(promise)
@@ -87,8 +87,8 @@ class DashXReactNativeModule(reactContext: ReactApplicationContext) :
87
87
  fun fetchAsset(assetId: String, promise: Promise) = impl.fetchAsset(assetId, promise)
88
88
 
89
89
  @ReactMethod
90
- fun requestNotificationPermission(promise: Promise) =
91
- impl.requestNotificationPermission(promise)
90
+ fun requestNotificationPermission(fallbackToSettings: Boolean, promise: Promise) =
91
+ impl.requestNotificationPermission(fallbackToSettings, promise)
92
92
 
93
93
  @ReactMethod
94
94
  fun getNotificationPermissionStatus(promise: Promise) =
package/index.d.ts CHANGED
@@ -235,9 +235,19 @@ export interface DashXInstance {
235
235
 
236
236
  /**
237
237
  * Request push notification permission from the user.
238
+ *
239
+ * @param options.fallbackToSettings — when `true`, if the user has previously
240
+ * granted or denied permission, opens the system notification settings
241
+ * page so they can enable Time Sensitive notifications (or re-enable
242
+ * notifications). Defaults to `false`. Use this to recover users who
243
+ * granted permission before the SDK started requesting `.timeSensitive`,
244
+ * since iOS won't silently add new options to an existing grant.
245
+ *
238
246
  * Resolves with a NotificationPermissionStatus integer.
239
247
  */
240
- requestNotificationPermission(): Promise<NotificationPermissionStatus>;
248
+ requestNotificationPermission(options?: {
249
+ fallbackToSettings?: boolean
250
+ }): Promise<NotificationPermissionStatus>;
241
251
 
242
252
  /**
243
253
  * Get the current push notification permission status without prompting.
package/index.js CHANGED
@@ -218,8 +218,9 @@ const DashX = {
218
218
  );
219
219
  },
220
220
 
221
- requestNotificationPermission() {
222
- return getNativeDashX().requestNotificationPermission();
221
+ requestNotificationPermission(options) {
222
+ const fallbackToSettings = Boolean(options && options.fallbackToSettings);
223
+ return getNativeDashX().requestNotificationPermission(fallbackToSettings);
223
224
  },
224
225
 
225
226
  getNotificationPermissionStatus() {
@@ -39,6 +39,15 @@ public final class DashXNotificationHandler: NSObject {
39
39
  body: bridgeSafePayload(from: userInfo)
40
40
  )
41
41
 
42
+ // Alert pushes (aps.alert present) are displayed by iOS directly via
43
+ // the Notification Service Extension. Skipping the silent-push
44
+ // reconstruction prevents a duplicate `add()` from racing the
45
+ // system-displayed banner and clobbering its NSE-attached image.
46
+ if let aps = userInfo["aps"] as? [AnyHashable: Any], aps["alert"] != nil {
47
+ completionHandler(.newData)
48
+ return
49
+ }
50
+
42
51
  guard let dashxData = userInfo.dashxNotificationData() else {
43
52
  completionHandler(.noData)
44
53
  return
@@ -74,13 +83,42 @@ public final class DashXNotificationHandler: NSObject {
74
83
  )
75
84
  UNUserNotificationCenter.current().setNotificationCategories([category])
76
85
 
86
+ // Defer the completion handler until after the local notification has
87
+ // actually been added. iOS suspends the process the moment we call
88
+ // `completionHandler`, so firing it before the async download + add
89
+ // chain finishes was dropping notifications mid-flight. The 25s budget
90
+ // sits comfortably under iOS's ~30s background-push limit.
91
+ let fire = makeOnceFire(completionHandler)
92
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(25)) {
93
+ fire(.newData)
94
+ }
95
+
77
96
  if let imagePath = dashxData.image, let imageURL = URL(string: imagePath) {
78
- createNotificationWithImage(id: dashxData.id, imageURL: imageURL, content: content)
97
+ createNotificationWithImage(id: dashxData.id, imageURL: imageURL, content: content) {
98
+ fire(.newData)
99
+ }
79
100
  } else {
80
- createNotification(id: dashxData.id, content: content)
101
+ createNotification(id: dashxData.id, content: content) {
102
+ fire(.newData)
103
+ }
81
104
  }
105
+ }
82
106
 
83
- completionHandler(.newData)
107
+ /// Wraps `completionHandler` so it can be invoked safely from any of the
108
+ /// (download done) / (add done) / (timeout) callbacks — second invocation
109
+ /// is a no-op.
110
+ private static func makeOnceFire(
111
+ _ completionHandler: @escaping (UIBackgroundFetchResult) -> Void
112
+ ) -> (UIBackgroundFetchResult) -> Void {
113
+ let lock = NSLock()
114
+ var fired = false
115
+ return { result in
116
+ lock.lock()
117
+ defer { lock.unlock() }
118
+ guard !fired else { return }
119
+ fired = true
120
+ completionHandler(result)
121
+ }
84
122
  }
85
123
 
86
124
  @objc
@@ -175,23 +213,29 @@ public final class DashXNotificationHandler: NSObject {
175
213
 
176
214
  // MARK: - Private Helpers
177
215
 
178
- private static func createNotification(id: String, content: UNMutableNotificationContent) {
216
+ private static func createNotification(
217
+ id: String,
218
+ content: UNMutableNotificationContent,
219
+ completion: @escaping () -> Void
220
+ ) {
179
221
  let request = UNNotificationRequest(identifier: id, content: content, trigger: nil)
180
222
  UNUserNotificationCenter.current().add(request) { error in
181
223
  if let error = error {
182
224
  DashXLog.d(tag: #function, "Failed to schedule notification: \(error.localizedDescription)")
183
225
  }
226
+ completion()
184
227
  }
185
228
  }
186
229
 
187
230
  private static func createNotificationWithImage(
188
231
  id: String,
189
232
  imageURL: URL,
190
- content: UNMutableNotificationContent
233
+ content: UNMutableNotificationContent,
234
+ completion: @escaping () -> Void
191
235
  ) {
192
236
  let task = URLSession.shared.downloadTask(with: imageURL) { location, _, error in
193
237
  guard let location = location, error == nil else {
194
- createNotification(id: id, content: content)
238
+ createNotification(id: id, content: content, completion: completion)
195
239
  return
196
240
  }
197
241
 
@@ -206,10 +250,10 @@ public final class DashXNotificationHandler: NSObject {
206
250
  options: nil
207
251
  )
208
252
  content.attachments = [attachment]
209
- createNotification(id: id, content: content)
210
253
  } catch {
211
- createNotification(id: id, content: content)
254
+ // fall through with no attachment
212
255
  }
256
+ createNotification(id: id, content: content, completion: completion)
213
257
  }
214
258
  task.resume()
215
259
  }
@@ -55,7 +55,7 @@ RCT_EXTERN_METHOD(processURL:(NSString *)url source:(NSString * _Nullable)source
55
55
 
56
56
  RCT_EXTERN_METHOD(trackNotificationNavigation:(NSDictionary * _Nullable)action notificationId:(NSString * _Nullable)notificationId);
57
57
 
58
- RCT_EXTERN_METHOD(requestNotificationPermission:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject);
58
+ RCT_EXTERN_METHOD(requestNotificationPermission:(BOOL)fallbackToSettings resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject);
59
59
 
60
60
  RCT_EXTERN_METHOD(getNotificationPermissionStatus:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject);
61
61
 
@@ -232,9 +232,9 @@ class DashXReactNative: RCTEventEmitter {
232
232
  DashX.trackNotificationNavigation(navigationAction, notificationId: notificationId as String?)
233
233
  }
234
234
 
235
- @objc(requestNotificationPermission:reject:)
236
- func requestNotificationPermission(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
237
- DashX.requestNotificationPermission { status in resolve(status.rawValue) }
235
+ @objc(requestNotificationPermission:resolve:reject:)
236
+ func requestNotificationPermission(_ fallbackToSettings: Bool, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
237
+ DashX.requestNotificationPermission(fallbackToSettings: fallbackToSettings) { status in resolve(status.rawValue) }
238
238
  }
239
239
 
240
240
  @objc(getNotificationPermissionStatus:reject:)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dashx/react-native",
3
3
  "title": "DashX React Native",
4
- "version": "1.4.0",
4
+ "version": "1.4.1",
5
5
  "description": "DashX SDK for React Native",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
@@ -32,7 +32,7 @@ export interface Spec extends TurboModule {
32
32
  action: Object | null,
33
33
  notificationId: string | null
34
34
  ): void;
35
- requestNotificationPermission(): Promise<number>;
35
+ requestNotificationPermission(fallbackToSettings: boolean): Promise<number>;
36
36
  getNotificationPermissionStatus(): Promise<number>;
37
37
 
38
38
  addListener(eventName: string): void;