@bubblsdk/react-native-sdk 5.0.0-alpha.2 → 5.0.0-alpha.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.0.0-alpha.3
4
+
5
+ - Silent geofence sync: the backend now dispatches a silent push when a geo campaign is published,
6
+ updated, or paused. The SDK wakes in the background, fetches the latest geofences, and registers
7
+ them with the OS — so devices receive notifications even if the app has never been opened in that
8
+ area. Requires `remote-notification` in `UIBackgroundModes` on iOS (already present in most
9
+ Bubbl-enabled apps).
10
+
3
11
  ## 5.0.0-alpha.2
4
12
 
5
13
  - `Bubbl.permissions.openSettings()` now works before `Bubbl.start` on Android, so an app can send people to its settings page (for example after a camera refusal) before it starts Bubbl. iOS 17+ already did. Below the minimum OS it stays a no-op.
@@ -45,5 +45,5 @@ repositories {
45
45
  dependencies {
46
46
  implementation "com.facebook.react:react-android"
47
47
  // The Bubbl Android SDK, the same version as this package (stamped by scripts/sync-version.mjs).
48
- implementation "tech.bubbl.sdk:bubbl-sdk:5.0.0-alpha.2"
48
+ implementation "tech.bubbl.sdk:bubbl-sdk:5.0.0-alpha.3"
49
49
  }
@@ -1,7 +1,10 @@
1
1
  import Foundation
2
+ #if canImport(UIKit)
3
+ import UIKit
4
+ #endif
2
5
  import UserNotifications
3
6
 
4
- /// The iOS SDK's three push hand-overs for an app with `BubblAutoIntegrationEnabled` = NO in
7
+ /// The iOS SDK's push hand-overs for an app with `BubblAutoIntegrationEnabled` = NO in
5
8
  /// Info.plist, callable from an Objective-C AppDelegate too (the SDK's `Bubbl` is Swift only).
6
9
  /// From Swift, `import BubblReactNativeSdk` and call `Bubbl.setPushToken(_:)` and the rest
7
10
  /// directly; from Objective-C:
@@ -12,6 +15,7 @@ import UserNotifications
12
15
  /// [BubblPush setPushToken:deviceToken];
13
16
  /// if ([BubblPush willPresent:notification completionHandler:completionHandler]) return;
14
17
  /// if ([BubblPush didReceive:response completionHandler:completionHandler]) return;
18
+ /// if ([BubblPush didReceiveRemoteNotification:userInfo completionHandler:completionHandler]) return;
15
19
  @objc(BubblPush)
16
20
  public final class BubblPush: NSObject {
17
21
  /// From `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`. Before start too.
@@ -37,6 +41,16 @@ public final class BubblPush: NSObject {
37
41
  Bubbl.didReceive(response, completionHandler: completionHandler)
38
42
  }
39
43
 
44
+ /// From `application(_:didReceiveRemoteNotification:fetchCompletionHandler:)`: true when the
45
+ /// remote notification is Bubbl's, and Bubbl calls `completionHandler`; false when it isn't.
46
+ @discardableResult
47
+ @objc public static func didReceiveRemoteNotification(
48
+ _ userInfo: [AnyHashable: Any],
49
+ completionHandler: @escaping (UIBackgroundFetchResult) -> Void
50
+ ) -> Bool {
51
+ Bubbl.didReceiveRemoteNotification(userInfo, completionHandler: completionHandler)
52
+ }
53
+
40
54
  /// Whether a push's payload is Bubbl's, so the app's own handling can leave it alone.
41
55
  @objc public static func isBubblMessage(_ userInfo: [AnyHashable: Any]) -> Bool {
42
56
  Bubbl.isBubblMessage(userInfo)
@@ -1,5 +1,5 @@
1
1
  /// The SDK's version, sent as X-Bubbl-SDK-Version. Stamped from version.txt by
2
2
  /// scripts/sync-version.mjs; don't edit it by hand.
3
3
  package enum BubblVersion {
4
- package static let sdk = "5.0.0-alpha.2"
4
+ package static let sdk = "5.0.0-alpha.3"
5
5
  }
@@ -28,7 +28,7 @@ extension EngineCore {
28
28
  switch message {
29
29
  case .test(let title, let body):
30
30
  return .test(title: title, body: body)
31
- case .unsupported:
31
+ case .syncGeofences, .unsupported:
32
32
  return .nothing
33
33
  case .full(_, let notification):
34
34
  return await notificationArrived(notification, opened: opened)
@@ -11,12 +11,14 @@ package enum PushMessage: Sendable, Equatable {
11
11
  case reference(campaignNotificationId: String)
12
12
  /// A dashboard test push: no campaign behind it, so nothing to fetch or report.
13
13
  case test(title: String, body: String)
14
+ /// A silent push asking the SDK to sync geofences in the background.
15
+ case syncGeofences
14
16
  /// Bubbl's, but not something this SDK can show (a newer format): not the app's to handle either.
15
17
  case unsupported(version: String)
16
18
 
17
19
  package static let version = "1"
18
20
  /// The data keys a Bubbl push carries (push.json).
19
- package static let keys = ["bubbl", "bubbl_v", "campaign_notification_id", "notification", "test", "title", "body", "image_url"]
21
+ package static let keys = ["bubbl", "bubbl_v", "action", "campaign_notification_id", "notification", "test", "title", "body", "image_url"]
20
22
 
21
23
  /// The picture for the system notification (image_url), for the notification service
22
24
  /// extension: there even when the notification itself was too big to come in the push. Only
@@ -43,6 +45,9 @@ package enum PushMessage: Sendable, Equatable {
43
45
  BubblLog.warning("A push in data format \(version) was left alone: this SDK reads format \(Self.version)")
44
46
  return .unsupported(version: version)
45
47
  }
48
+ if data["action"]?.stringValue == "sync_geofences" {
49
+ return .syncGeofences
50
+ }
46
51
  if isSet(data["test"]) {
47
52
  return .test(title: data["title"]?.stringValue ?? "", body: data["body"]?.stringValue ?? "")
48
53
  }
@@ -1,5 +1,5 @@
1
1
  /// The SDK's version, sent as X-Bubbl-SDK-Version. Stamped from version.txt by
2
2
  /// scripts/sync-version.mjs; don't edit it by hand.
3
3
  package enum BubblVersion {
4
- package static let sdk = "5.0.0-alpha.2"
4
+ package static let sdk = "5.0.0-alpha.3"
5
5
  }
@@ -28,7 +28,7 @@ extension EngineCore {
28
28
  switch message {
29
29
  case .test(let title, let body):
30
30
  return .test(title: title, body: body)
31
- case .unsupported:
31
+ case .syncGeofences, .unsupported:
32
32
  return .nothing
33
33
  case .full(_, let notification):
34
34
  return await notificationArrived(notification, opened: opened)
@@ -11,12 +11,14 @@ package enum PushMessage: Sendable, Equatable {
11
11
  case reference(campaignNotificationId: String)
12
12
  /// A dashboard test push: no campaign behind it, so nothing to fetch or report.
13
13
  case test(title: String, body: String)
14
+ /// A silent push asking the SDK to sync geofences in the background.
15
+ case syncGeofences
14
16
  /// Bubbl's, but not something this SDK can show (a newer format): not the app's to handle either.
15
17
  case unsupported(version: String)
16
18
 
17
19
  package static let version = "1"
18
20
  /// The data keys a Bubbl push carries (push.json).
19
- package static let keys = ["bubbl", "bubbl_v", "campaign_notification_id", "notification", "test", "title", "body", "image_url"]
21
+ package static let keys = ["bubbl", "bubbl_v", "action", "campaign_notification_id", "notification", "test", "title", "body", "image_url"]
20
22
 
21
23
  /// The picture for the system notification (image_url), for the notification service
22
24
  /// extension: there even when the notification itself was too big to come in the push. Only
@@ -43,6 +45,9 @@ package enum PushMessage: Sendable, Equatable {
43
45
  BubblLog.warning("A push in data format \(version) was left alone: this SDK reads format \(Self.version)")
44
46
  return .unsupported(version: version)
45
47
  }
48
+ if data["action"]?.stringValue == "sync_geofences" {
49
+ return .syncGeofences
50
+ }
46
51
  if isSet(data["test"]) {
47
52
  return .test(title: data["title"]?.stringValue ?? "", body: data["body"]?.stringValue ?? "")
48
53
  }
@@ -1,4 +1,7 @@
1
1
  import Foundation
2
+ #if canImport(UIKit)
3
+ import UIKit
4
+ #endif
2
5
  #if canImport(UserNotifications)
3
6
  import UserNotifications
4
7
  #endif
@@ -222,6 +225,14 @@ public enum Bubbl {
222
225
  public static func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool {
223
226
  backend.didReceive(response, completionHandler: completionHandler)
224
227
  }
228
+
229
+ /// From the app delegate's `application(_:didReceiveRemoteNotification:fetchCompletionHandler:)`:
230
+ /// true when the remote notification is Bubbl's, and Bubbl calls `completionHandler`; false when it
231
+ /// isn't.
232
+ @discardableResult
233
+ public static func didReceiveRemoteNotification(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool {
234
+ backend.didReceiveRemoteNotification(userInfo, completionHandler: completionHandler)
235
+ }
225
236
  #endif
226
237
 
227
238
  // MARK: - Diagnostics
@@ -1,5 +1,8 @@
1
1
  import Foundation
2
2
  import os
3
+ #if canImport(UIKit)
4
+ import UIKit
5
+ #endif
3
6
  #if canImport(UserNotifications)
4
7
  import UserNotifications
5
8
  #endif
@@ -40,6 +43,7 @@ protocol BubblBackend: Sendable {
40
43
  func setPushToken(_ deviceToken: Data)
41
44
  func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool
42
45
  func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool
46
+ func didReceiveRemoteNotification(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool
43
47
  #endif
44
48
 
45
49
  func permissionStatus() -> BubblPermissionStatus?
@@ -94,6 +98,7 @@ struct UnsupportedBubbl: BubblBackend {
94
98
  func setPushToken(_ deviceToken: Data) {}
95
99
  func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { false }
96
100
  func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool { false }
101
+ func didReceiveRemoteNotification(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { false }
97
102
  #endif
98
103
 
99
104
  func permissionStatus() -> BubblPermissionStatus? { nil }
@@ -1,4 +1,7 @@
1
1
  import Foundation
2
+ #if canImport(UIKit)
3
+ import UIKit
4
+ #endif
2
5
  #if canImport(UserNotifications)
3
6
  import UserNotifications
4
7
  #endif
@@ -151,4 +154,33 @@ extension EngineHost {
151
154
  break
152
155
  }
153
156
  }
157
+
158
+ #if os(iOS)
159
+ /// A remote notification arrived via application(_:didReceiveRemoteNotification:fetchCompletionHandler:).
160
+ /// Returns the UIBackgroundFetchResult to hand iOS's completion handler.
161
+ func receivedRemoteNotification(_ data: [String: JSONValue]) async -> UIBackgroundFetchResult {
162
+ if current == nil {
163
+ applicationDidFinishLaunching()
164
+ }
165
+ guard let core = current, core.isActive else { return .noData }
166
+
167
+ guard let message = PushMessage.parse(data) else { return .noData }
168
+ switch message {
169
+ case .syncGeofences:
170
+ BubblLog.info("Silent push received: syncing geofences")
171
+ let outcome = await runCheck(core, force: true, precise: false, rewatch: false, fix: nil)
172
+ switch outcome {
173
+ case .done:
174
+ return .newData
175
+ case .retry:
176
+ return .failed
177
+ }
178
+ case .full, .reference:
179
+ _ = await presentation(forArriving: data)
180
+ return .newData
181
+ case .test, .unsupported:
182
+ return .noData
183
+ }
184
+ }
185
+ #endif
154
186
  }
@@ -208,6 +208,10 @@ struct SupportedBubbl: BubblBackend {
208
208
  func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool {
209
209
  NotificationDelegateHooks.didReceive(response, completionHandler)
210
210
  }
211
+
212
+ func didReceiveRemoteNotification(_ userInfo: [AnyHashable: Any], completionHandler: @escaping (UIBackgroundFetchResult) -> Void) -> Bool {
213
+ PushIntegration.didReceiveRemoteNotification(userInfo, completionHandler)
214
+ }
211
215
  #endif
212
216
 
213
217
  // MARK: - Permissions
@@ -196,7 +196,7 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
196
196
  }
197
197
 
198
198
  oneShot.desiredAccuracy = precise ? kCLLocationAccuracyBest : kCLLocationAccuracyHundredMeters
199
- return await withCheckedContinuation { continuation in
199
+ let fresh: Fix? = await withCheckedContinuation { continuation in
200
200
  waiting.append(continuation)
201
201
  guard waiting.count == 1 else { return }
202
202
  fixRequest += 1
@@ -207,6 +207,16 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
207
207
  if self.fixRequest == request { self.deliver(nil) }
208
208
  }
209
209
  }
210
+ if let fresh { return fresh }
211
+
212
+ // When a fresh fix couldn't be obtained in time (e.g. indoors or during a background wake-up),
213
+ // fall back to the most recent cached location if available, as Android does with fused.lastLocation.
214
+ let candidates = [oneShot.location, inUse.location, changes.location].compactMap { $0 }
215
+ if let mostRecent = candidates.max(by: { $0.timestamp < $1.timestamp }) {
216
+ BubblLog.debug("Geofence check: using cached location from \(-Int(mostRecent.timestamp.timeIntervalSinceNow))s ago")
217
+ return Fix(mostRecent)
218
+ }
219
+ return nil
210
220
  }
211
221
 
212
222
  /// While the app is open and iOS isn't watching the geofences: fixes every 20 m or so.
@@ -80,6 +80,18 @@ enum PushIntegration {
80
80
  await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
81
81
  }
82
82
  }
83
+
84
+ /// Bubbl answers for a remote notification (e.g. background geofence sync push); false when it isn't Bubbl's.
85
+ static func didReceiveRemoteNotification(_ userInfo: [AnyHashable: Any], _ completion: @escaping (UIBackgroundFetchResult) -> Void) -> Bool {
86
+ let data = EngineHost.pushData(userInfo)
87
+ guard PushMessage.isBubbl(data) else { return false }
88
+ let done = Handed(completion)
89
+ Task { @MainActor in
90
+ let result = await EngineHost.shared.receivedRemoteNotification(data)
91
+ done.value(result)
92
+ }
93
+ return true
94
+ }
83
95
  }
84
96
 
85
97
  // MARK: - The app delegate's token callbacks
@@ -88,6 +100,7 @@ enum PushIntegration {
88
100
  enum AppDelegateHooks {
89
101
  private typealias DidRegister = @convention(c) (AnyObject, Selector, UIApplication, NSData) -> Void
90
102
  private typealias DidFail = @convention(c) (AnyObject, Selector, UIApplication, NSError) -> Void
103
+ private typealias DidReceiveRemote = @convention(c) (AnyObject, Selector, UIApplication, NSDictionary, @escaping @convention(block) (UIBackgroundFetchResult) -> Void) -> Void
91
104
 
92
105
  @MainActor
93
106
  static func install() {
@@ -123,6 +136,29 @@ enum AppDelegateHooks {
123
136
  }
124
137
  return block
125
138
  }
139
+
140
+ let didReceiveRemote = #selector(UIApplicationDelegate.application(_:didReceiveRemoteNotification:fetchCompletionHandler:))
141
+ Swizzle.extend(cls, didReceiveRemote, types: "v@:@@@?") { original in
142
+ let block: @convention(block) (AnyObject, UIApplication, NSDictionary, @escaping @convention(block) (UIBackgroundFetchResult) -> Void) -> Void = { this, application, userInfo, completion in
143
+ let dict = userInfo as? [AnyHashable: Any] ?? [:]
144
+ if PushIntegration.didReceiveRemoteNotification(dict, completion) { return }
145
+ if let original {
146
+ unsafeBitCast(original, to: DidReceiveRemote.self)(this, didReceiveRemote, application, userInfo, completion)
147
+ } else if let target = Swizzle.forwardee(this, didReceiveRemote) {
148
+ if let delegate = target as? UIApplicationDelegate {
149
+ delegate.application?(application, didReceiveRemoteNotification: dict, fetchCompletionHandler: completion)
150
+ } else if let method = class_getInstanceMethod(object_getClass(target), didReceiveRemote) {
151
+ let imp = method_getImplementation(method)
152
+ unsafeBitCast(imp, to: DidReceiveRemote.self)(target, didReceiveRemote, application, userInfo, completion)
153
+ } else {
154
+ completion(.noData)
155
+ }
156
+ } else {
157
+ completion(.noData)
158
+ }
159
+ }
160
+ return block
161
+ }
126
162
  }
127
163
  }
128
164
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblsdk/react-native-sdk",
3
- "version": "5.0.0-alpha.2",
3
+ "version": "5.0.0-alpha.3",
4
4
  "description": "Bubbl for React Native: geofenced and scheduled notifications, surveys and analytics, over the native Bubbl SDKs.",
5
5
  "main": "./lib/commonjs/index.js",
6
6
  "module": "./lib/module/index.js",