@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 +0 -0
- package/CHANGELOG.md +39 -0
- package/DashXReactNative.podspec +8 -2
- package/android/src/main/java/com/dashx/rn/sdk/DashXReactNativeModuleImpl.kt +37 -5
- package/android/src/newarch/java/com/dashx/rn/sdk/DashXReactNativeModule.kt +2 -2
- package/android/src/oldarch/java/com/dashx/rn/sdk/DashXReactNativeModule.kt +2 -2
- package/index.d.ts +11 -1
- package/index.js +3 -2
- package/ios/DashXNotificationHandler.swift +52 -8
- package/ios/DashXReactNative.mm +1 -1
- package/ios/DashXReactNative.swift +3 -3
- package/package.json +1 -1
- package/src/NativeDashXReactNative.ts +1 -1
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
|
package/DashXReactNative.podspec
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
254
|
+
// fall through with no attachment
|
|
212
255
|
}
|
|
256
|
+
createNotification(id: id, content: content, completion: completion)
|
|
213
257
|
}
|
|
214
258
|
task.resume()
|
|
215
259
|
}
|
package/ios/DashXReactNative.mm
CHANGED
|
@@ -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
|
@@ -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;
|