@bubblsdk/react-native-sdk 5.0.0-alpha.2 → 5.0.0-alpha.4
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 +15 -0
- package/android/build.gradle +1 -1
- package/ios/BubblPush.swift +15 -1
- package/ios-extension/Sources/BubblCore/BubblVersion.swift +1 -1
- package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +1 -1
- package/ios-extension/Sources/BubblCore/Notification/Push.swift +6 -1
- package/ios-sdk/BubblCore/BubblVersion.swift +1 -1
- package/ios-sdk/BubblCore/Engine/Pushes.swift +1 -1
- package/ios-sdk/BubblCore/Notification/Push.swift +6 -1
- package/ios-sdk/BubblSDK/Bubbl.swift +11 -0
- package/ios-sdk/BubblSDK/BubblBackend.swift +5 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +32 -0
- package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +4 -0
- package/ios-sdk/BubblSDK/Platform/LocationService.swift +11 -1
- package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +36 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.0.0-alpha.4
|
|
4
|
+
|
|
5
|
+
- Android: notification and permission screens no longer use the deprecated
|
|
6
|
+
`LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES` (flagged by Google Play on Android 15). They use
|
|
7
|
+
`LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` on Android 11 and up; on Android 9 and 10 the system default
|
|
8
|
+
applies, so in landscape the dim stops at the cutout.
|
|
9
|
+
|
|
10
|
+
## 5.0.0-alpha.3
|
|
11
|
+
|
|
12
|
+
- Silent geofence sync: the backend now dispatches a silent push when a geo campaign is published,
|
|
13
|
+
updated, or paused. The SDK wakes in the background, fetches the latest geofences, and registers
|
|
14
|
+
them with the OS — so devices receive notifications even if the app has never been opened in that
|
|
15
|
+
area. Requires `remote-notification` in `UIBackgroundModes` on iOS (already present in most
|
|
16
|
+
Bubbl-enabled apps).
|
|
17
|
+
|
|
3
18
|
## 5.0.0-alpha.2
|
|
4
19
|
|
|
5
20
|
- `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.
|
package/android/build.gradle
CHANGED
|
@@ -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.
|
|
48
|
+
implementation "tech.bubbl.sdk:bubbl-sdk:5.0.0-alpha.4"
|
|
49
49
|
}
|
package/ios/BubblPush.swift
CHANGED
|
@@ -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
|
|
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)
|
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "5.0.0-alpha.4",
|
|
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",
|