@bubblsdk/react-native-sdk 4.1.7 → 5.0.0-alpha.2
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/BubblReactNativeSdk.podspec +29 -9
- package/CHANGELOG.md +52 -1
- package/LICENSE +202 -1
- package/README.md +172 -83
- package/android/build.gradle +33 -30
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
- package/app.plugin.js +2 -0
- package/ios/BubblPush.swift +44 -0
- package/ios/BubblSdk.h +7 -0
- package/ios/BubblSdk.mm +252 -0
- package/ios/BubblSdkImpl.swift +335 -0
- package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
- package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
- package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
- package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
- package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
- package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
- package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
- package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
- package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
- package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
- package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-sdk/BubblCore/API/Http.swift +39 -0
- package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
- package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
- package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
- package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
- package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
- package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
- package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
- package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
- package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
- package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
- package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
- package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
- package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
- package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
- package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
- package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
- package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
- package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
- package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
- package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
- package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
- package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
- package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
- package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
- package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
- package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
- package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
- package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
- package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
- package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
- package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
- package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
- package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
- package/jest/index.js +75 -0
- package/lib/commonjs/BubblModule.js +14 -0
- package/lib/commonjs/BubblModule.js.map +1 -0
- package/lib/commonjs/BubblModule.web.js +13 -0
- package/lib/commonjs/BubblModule.web.js.map +1 -0
- package/lib/commonjs/NativeBubblSdk.js +20 -0
- package/lib/commonjs/NativeBubblSdk.js.map +1 -0
- package/lib/commonjs/index.js +336 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/BubblModule.js +5 -0
- package/lib/module/BubblModule.js.map +1 -0
- package/lib/module/BubblModule.web.js +9 -0
- package/lib/module/BubblModule.web.js.map +1 -0
- package/lib/module/NativeBubblSdk.js +18 -0
- package/lib/module/NativeBubblSdk.js.map +1 -0
- package/lib/module/index.js +331 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +111 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/types.d.ts +192 -0
- package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/BubblModule.d.ts +3 -0
- package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +111 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/types.d.ts +192 -0
- package/lib/typescript/module/src/types.d.ts.map +1 -0
- package/package.json +105 -24
- package/plugin/build/imageNotifications.d.ts +35 -0
- package/plugin/build/imageNotifications.js +198 -0
- package/plugin/build/index.d.ts +84 -0
- package/plugin/build/index.js +176 -0
- package/src/BubblModule.ts +2 -0
- package/src/BubblModule.web.ts +9 -0
- package/src/NativeBubblSdk.ts +67 -0
- package/src/index.ts +338 -282
- package/src/types.ts +182 -0
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
- package/ios/BubblSdk.swift +0 -802
- package/ios/BubblSdkBridge.m +0 -86
- package/react-native.config.js +0 -11
- package/src/specs/NativeBubblSdk.ts +0 -54
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
#if !COCOAPODS
|
|
3
|
+
import BubblCore
|
|
4
|
+
#endif
|
|
5
|
+
|
|
6
|
+
/// Where the device stands on the permissions Bubbl uses. The same as Android's.
|
|
7
|
+
public struct BubblPermissionStatus: Sendable, Equatable {
|
|
8
|
+
public enum Notifications: Sendable, Equatable { case granted, denied, notDetermined }
|
|
9
|
+
public enum Location: Sendable, Equatable { case always, whenInUse, denied, notDetermined }
|
|
10
|
+
|
|
11
|
+
public let notifications: Notifications
|
|
12
|
+
public let location: Location
|
|
13
|
+
/// Precise rather than approximate location (geofences need it).
|
|
14
|
+
public let preciseLocation: Bool
|
|
15
|
+
|
|
16
|
+
init(_ status: PermissionStatus) {
|
|
17
|
+
notifications = switch status.notifications {
|
|
18
|
+
case .granted: .granted
|
|
19
|
+
case .denied: .denied
|
|
20
|
+
case .notDetermined: .notDetermined
|
|
21
|
+
}
|
|
22
|
+
location = switch status.location {
|
|
23
|
+
case .always: .always
|
|
24
|
+
case .whenInUse: .whenInUse
|
|
25
|
+
case .denied: .denied
|
|
26
|
+
case .notDetermined: .notDetermined
|
|
27
|
+
}
|
|
28
|
+
preciseLocation = status.preciseLocation
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// The permissions Bubbl uses (`Bubbl.permissions`): where they stand, and asking for them. Asking
|
|
33
|
+
/// shows the privacy view first when the dashboard says to, then the system's prompt; it works from
|
|
34
|
+
/// anywhere in the app. iOS prompts for each permission once: after a refusal, `openSettings()`.
|
|
35
|
+
public struct BubblPermissions: Sendable {
|
|
36
|
+
/// Where the device stands, as last looked (at start, when the app comes to the front, after
|
|
37
|
+
/// asking); nil before `Bubbl.start`, and where Bubbl isn't supported (below iOS 17).
|
|
38
|
+
public func status() -> BubblPermissionStatus? {
|
|
39
|
+
Bubbl.backend.permissionStatus()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Notifications: the privacy view if the dashboard says so, then the system prompt.
|
|
43
|
+
public func requestNotifications() async -> BubblPermissionStatus? {
|
|
44
|
+
await request(.notifications, "permissions.requestNotifications")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Location: the privacy view if the dashboard says so, then the system prompt; with `always`,
|
|
48
|
+
/// the second step to "Always" too (geofences with the app closed).
|
|
49
|
+
public func requestLocation(always: Bool = false) async -> BubblPermissionStatus? {
|
|
50
|
+
await request(always ? .locationAlways : .locationWhenInUse, "permissions.requestLocation")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// `requestNotifications()` for code that can't await; `completion` runs on the main thread.
|
|
54
|
+
public func requestNotifications(completion: @escaping @MainActor @Sendable (BubblPermissionStatus?) -> Void) {
|
|
55
|
+
Task {
|
|
56
|
+
let result = await requestNotifications()
|
|
57
|
+
await MainActor.run { completion(result) }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// `requestLocation(always:)` for code that can't await; `completion` runs on the main thread.
|
|
62
|
+
public func requestLocation(always: Bool, completion: @escaping @MainActor @Sendable (BubblPermissionStatus?) -> Void) {
|
|
63
|
+
Task {
|
|
64
|
+
let result = await requestLocation(always: always)
|
|
65
|
+
await MainActor.run { completion(result) }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// The app's page in the system's Settings, for a permission the user refused (one of the app's
|
|
70
|
+
/// own, like the camera, too). Works before `Bubbl.start`: it needs nothing of Bubbl's. Opens on
|
|
71
|
+
/// the main thread, whichever thread this is called from.
|
|
72
|
+
public func openSettings() {
|
|
73
|
+
Bubbl.backend.openSettings()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private func request(_ request: PermissionRequest, _ call: String) async -> BubblPermissionStatus? {
|
|
77
|
+
await Bubbl.backend.requestPermission(request, call)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// What `Bubbl.registerTestDevice` came to: approved as a Sandbox test device, or not, and why.
|
|
4
|
+
public struct BubblTestDeviceResult: Sendable, Equatable {
|
|
5
|
+
public let approved: Bool
|
|
6
|
+
/// Why not, when not approved, to show whoever typed the code: the server's words (a wrong,
|
|
7
|
+
/// used or expired code, the Sandbox's test devices all taken) or the SDK's (before `start`,
|
|
8
|
+
/// no network, below iOS 17). Nil when approved.
|
|
9
|
+
public let message: String?
|
|
10
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#if os(iOS)
|
|
2
|
+
import BackgroundTasks
|
|
3
|
+
import Foundation
|
|
4
|
+
#if !COCOAPODS
|
|
5
|
+
import BubblCore
|
|
6
|
+
#endif
|
|
7
|
+
|
|
8
|
+
/// Background app refresh (optional): iOS wakes the app now and then, and Bubbl sends what's queued
|
|
9
|
+
/// and refreshes its config, so events don't wait for the app's next run. Only when the app allows
|
|
10
|
+
/// it in its Info.plist: "tech.bubbl.sdk.refresh" in BGTaskSchedulerPermittedIdentifiers, and
|
|
11
|
+
/// "fetch" in UIBackgroundModes (Background fetch). Without those, events go when the app goes to
|
|
12
|
+
/// the background and on its next run, as before.
|
|
13
|
+
///
|
|
14
|
+
/// iOS wants the handler registered before launch finishes (later is an exception), which is why
|
|
15
|
+
/// it's done from BubblLaunch's +load, before main().
|
|
16
|
+
@available(iOS 17, *)
|
|
17
|
+
enum BackgroundRefresh {
|
|
18
|
+
static let identifier = "tech.bubbl.sdk.refresh"
|
|
19
|
+
|
|
20
|
+
private static let lock = NSLock()
|
|
21
|
+
nonisolated(unsafe) private static var registered = false
|
|
22
|
+
|
|
23
|
+
/// The app lets Bubbl refresh in the background.
|
|
24
|
+
static var declared: Bool {
|
|
25
|
+
let identifiers = Bundle.main.object(forInfoDictionaryKey: "BGTaskSchedulerPermittedIdentifiers") as? [String] ?? []
|
|
26
|
+
let modes = Bundle.main.object(forInfoDictionaryKey: "UIBackgroundModes") as? [String] ?? []
|
|
27
|
+
return identifiers.contains(identifier) && modes.contains("fetch")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// From +load. (Not main-actor code: iOS runs the handler on a queue of its own.)
|
|
31
|
+
static func register() {
|
|
32
|
+
guard declared else { return }
|
|
33
|
+
let done = BGTaskScheduler.shared.register(forTaskWithIdentifier: identifier, using: nil) { task in
|
|
34
|
+
EngineHost.shared.backgroundRefresh(task)
|
|
35
|
+
}
|
|
36
|
+
lock.sync { registered = done }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Asks iOS for the next refresh, no sooner than half an hour from now (iOS picks the time).
|
|
40
|
+
static func schedule() {
|
|
41
|
+
guard lock.sync({ registered }) else { return }
|
|
42
|
+
let request = BGAppRefreshTaskRequest(identifier: identifier)
|
|
43
|
+
request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
|
|
44
|
+
do {
|
|
45
|
+
try BGTaskScheduler.shared.submit(request)
|
|
46
|
+
} catch {
|
|
47
|
+
BubblLog.debug("Background refresh not scheduled (\(type(of: error)))")
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@available(iOS 17, *)
|
|
53
|
+
extension EngineHost {
|
|
54
|
+
/// iOS woke the app for a background refresh: the next one asked for, then the config, the
|
|
55
|
+
/// device, what's queued and segments sent (maintenance), within the time iOS gives.
|
|
56
|
+
func backgroundRefresh(_ task: BGTask) {
|
|
57
|
+
BackgroundRefresh.schedule()
|
|
58
|
+
// Launched in the background for this: Bubbl starts itself as the app last started it.
|
|
59
|
+
if current == nil { applicationDidFinishLaunching() }
|
|
60
|
+
|
|
61
|
+
let handed = Handed(task)
|
|
62
|
+
let finished = Once()
|
|
63
|
+
guard let core = current, core.isActive else {
|
|
64
|
+
finished.run { handed.value.setTaskCompleted(success: true) }
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
let work = Task {
|
|
68
|
+
_ = await core.maintenance()
|
|
69
|
+
finished.run { handed.value.setTaskCompleted(success: true) }
|
|
70
|
+
}
|
|
71
|
+
task.expirationHandler = {
|
|
72
|
+
work.cancel()
|
|
73
|
+
finished.run { handed.value.setTaskCompleted(success: false) }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Runs its body once, whichever caller gets there first.
|
|
79
|
+
@available(iOS 17, *)
|
|
80
|
+
final class Once: @unchecked Sendable {
|
|
81
|
+
private let lock = NSLock()
|
|
82
|
+
private var done = false
|
|
83
|
+
|
|
84
|
+
func run(_ body: () -> Void) {
|
|
85
|
+
let first: Bool = lock.sync {
|
|
86
|
+
defer { done = true }
|
|
87
|
+
return !done
|
|
88
|
+
}
|
|
89
|
+
if first { body() }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
#endif
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
#if canImport(UIKit)
|
|
3
|
+
import UIKit
|
|
4
|
+
#endif
|
|
5
|
+
#if !COCOAPODS
|
|
6
|
+
import BubblCore
|
|
7
|
+
#endif
|
|
8
|
+
|
|
9
|
+
/// Geofencing in the host, as Android's GeofenceWork: what CoreLocation reports becomes saved
|
|
10
|
+
/// transitions, sent at once in the time iOS gives the event; checks (fetch when due, watch the
|
|
11
|
+
/// nearest, re-watch after a failure or a change of permission) run as work, retried with backoff.
|
|
12
|
+
@available(iOS 17, *)
|
|
13
|
+
extension EngineHost {
|
|
14
|
+
static func regionMonitor() -> any RegionMonitor {
|
|
15
|
+
#if os(iOS)
|
|
16
|
+
CoreLocationRegionMonitor()
|
|
17
|
+
#else
|
|
18
|
+
InactiveRegionMonitor()
|
|
19
|
+
#endif
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Makes CoreLocation's managers now, on the main thread: an app relaunched for a region event
|
|
23
|
+
/// gets the event only once they exist.
|
|
24
|
+
func startLocation() {
|
|
25
|
+
#if os(iOS)
|
|
26
|
+
if Thread.isMainThread {
|
|
27
|
+
MainActor.assumeIsolated { LocationService.shared.activate() }
|
|
28
|
+
} else {
|
|
29
|
+
Task { @MainActor in LocationService.shared.activate() }
|
|
30
|
+
}
|
|
31
|
+
#endif
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Check the geofences soon: fetch them if due (or `force`), from `fix` or a fresh one
|
|
35
|
+
/// (`precise` for a polygon), watching them again first if `rewatch`. A check asked for while
|
|
36
|
+
/// one runs replaces any other waiting.
|
|
37
|
+
func checkGeofences(force: Bool = false, precise: Bool = false, rewatch: Bool = false, fix: Fix? = nil) {
|
|
38
|
+
#if os(iOS)
|
|
39
|
+
submit("geofences.check") { core in
|
|
40
|
+
await self.runCheck(core, force: force, precise: precise, rewatch: rewatch, fix: fix)
|
|
41
|
+
}
|
|
42
|
+
#endif
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// The app came to the front: where iOS isn't watching the geofences, they're checked from
|
|
46
|
+
/// fixes while it's open.
|
|
47
|
+
func appOpenedForLocation() {
|
|
48
|
+
#if os(iOS)
|
|
49
|
+
guard let core = current, core.locationActive else { return }
|
|
50
|
+
Task { @MainActor in LocationService.shared.startInUseUpdates() }
|
|
51
|
+
checkGeofences()
|
|
52
|
+
#endif
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#if os(iOS)
|
|
56
|
+
@MainActor
|
|
57
|
+
func appWentToTheBackForLocation() {
|
|
58
|
+
LocationService.shared.stopInUseUpdates()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// MARK: - From LocationService (main thread)
|
|
62
|
+
|
|
63
|
+
/// CoreLocation says the device entered or left Bubbl's regions `ids`.
|
|
64
|
+
@MainActor
|
|
65
|
+
func regionEvent(_ ids: [String], entered: Bool, fix: Fix?, deviceTimeMillis: Int64) {
|
|
66
|
+
guard let core = current else { return }
|
|
67
|
+
let time = BackgroundTime("tech.bubbl.sdk.geofence")
|
|
68
|
+
Task {
|
|
69
|
+
let outcome = await core.regionEvent(ids, entered: entered, fix: fix, deviceTimeMillis: deviceTimeMillis)
|
|
70
|
+
await act(on: outcome, core, fix: nil)
|
|
71
|
+
time.end()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// A fix: a significant change (`osWatches`), or one while the app is open with location only
|
|
76
|
+
/// while in use.
|
|
77
|
+
@MainActor
|
|
78
|
+
func locationFix(_ fix: Fix, osWatches: Bool) {
|
|
79
|
+
guard let core = current else { return }
|
|
80
|
+
let time = BackgroundTime("tech.bubbl.sdk.location")
|
|
81
|
+
Task {
|
|
82
|
+
let outcome = await core.locationFix(fix, osWatches: osWatches)
|
|
83
|
+
await act(on: outcome, core, fix: fix)
|
|
84
|
+
time.end()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// CoreLocation couldn't watch a region: the next check watches them all again (not now, which
|
|
89
|
+
/// would go round and round while iOS keeps refusing).
|
|
90
|
+
@MainActor
|
|
91
|
+
func watchFailed() {
|
|
92
|
+
guard let core = current else { return }
|
|
93
|
+
Task { try? await core.geofences.watchFailed() }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// The user changed the app's location permission.
|
|
97
|
+
@MainActor
|
|
98
|
+
func locationAccessChanged(_ access: LocationAccess) {
|
|
99
|
+
guard let core = current, core.locationActive else { return }
|
|
100
|
+
switch access {
|
|
101
|
+
case .none:
|
|
102
|
+
Task { await LocationService.shared.stopAll() }
|
|
103
|
+
case .whenInUse, .always:
|
|
104
|
+
if UIApplication.shared.applicationState != .background { LocationService.shared.startInUseUpdates() }
|
|
105
|
+
checkGeofences(rewatch: true)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// MARK: - Work
|
|
110
|
+
|
|
111
|
+
/// Android's CheckGeofencesWorker, with the fix from CoreLocation.
|
|
112
|
+
func runCheck(_ core: EngineCore, force: Bool, precise: Bool, rewatch: Bool, fix: Fix?) async -> WorkOutcome {
|
|
113
|
+
guard core.locationActive else { return .done }
|
|
114
|
+
let (access, osWatches) = await MainActor.run { (LocationService.shared.access, LocationService.shared.osWatches) }
|
|
115
|
+
guard access != .none else { return .done }
|
|
116
|
+
|
|
117
|
+
let current: Fix?
|
|
118
|
+
if let fix { current = fix } else { current = await LocationService.shared.currentFix(precise: precise) }
|
|
119
|
+
guard let current else {
|
|
120
|
+
BubblLog.debug("Geofence check: no location fix")
|
|
121
|
+
return .done
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let result = await core.checkGeofences(fix: current, force: force, rewatch: rewatch, osWatches: osWatches)
|
|
125
|
+
if core.hasPendingTransitions { await sendTransitionsNow(core) }
|
|
126
|
+
return result
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// What an event or a fix led to: its transitions sent now, and a check if it asked for one,
|
|
130
|
+
/// all in the time iOS gave the event.
|
|
131
|
+
private func act(on outcome: Outcome, _ core: EngineCore, fix: Fix?) async {
|
|
132
|
+
for transition in outcome.transitions {
|
|
133
|
+
BubblEvents.shared.emit(transition.enter ? .geofenceEntered(locationId: transition.locationId) : .geofenceExited(locationId: transition.locationId))
|
|
134
|
+
}
|
|
135
|
+
if !outcome.transitions.isEmpty { await sendTransitionsNow(core) }
|
|
136
|
+
guard outcome.refresh || outcome.needsFix else { return }
|
|
137
|
+
let checked = await runCheck(core, force: outcome.refresh, precise: outcome.needsFix, rewatch: false, fix: outcome.needsFix ? nil : fix)
|
|
138
|
+
if case .retry = checked { checkGeofences(force: outcome.refresh, precise: outcome.needsFix) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Send the saved transitions now; what couldn't go is retried, with backoff, as work.
|
|
142
|
+
private func sendTransitionsNow(_ core: EngineCore) async {
|
|
143
|
+
if case .retry = await sendTransitions(core) {
|
|
144
|
+
submit("geofences.transitions") { await self.sendTransitions($0) }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private func sendTransitions(_ core: EngineCore) async -> WorkOutcome {
|
|
149
|
+
let sent = await core.sendTransitions()
|
|
150
|
+
await arrived(sent.notifications, core)
|
|
151
|
+
if sent.recheck { checkGeofences(force: true) }
|
|
152
|
+
return sent.outcome
|
|
153
|
+
}
|
|
154
|
+
#endif
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#if canImport(UIKit) && !os(watchOS)
|
|
158
|
+
/// The few seconds iOS gives to finish something in the background, asked for when it starts and
|
|
159
|
+
/// handed back when it's done (or when iOS says time is up).
|
|
160
|
+
@available(iOS 17, *)
|
|
161
|
+
@MainActor
|
|
162
|
+
final class BackgroundTime {
|
|
163
|
+
private var id = UIBackgroundTaskIdentifier.invalid
|
|
164
|
+
|
|
165
|
+
init(_ name: String) {
|
|
166
|
+
id = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
|
|
167
|
+
MainActor.assumeIsolated { self?.end() }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func end() {
|
|
172
|
+
guard id != .invalid else { return }
|
|
173
|
+
UIApplication.shared.endBackgroundTask(id)
|
|
174
|
+
id = .invalid
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
#endif
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
#if !COCOAPODS
|
|
3
|
+
import BubblCore
|
|
4
|
+
#endif
|
|
5
|
+
|
|
6
|
+
/// The user's decisions (Bubbl.setConsent, optOut, deleteMyData, setLocationEnabled) in the host:
|
|
7
|
+
/// the engine changes its state and stops what it must (EngineCore's PrivacyControls); the host
|
|
8
|
+
/// cancels its waiting work and runs what each needs, retried until done.
|
|
9
|
+
@available(iOS 17, *)
|
|
10
|
+
extension EngineHost {
|
|
11
|
+
/// Consent given (again): everything consent allows starts, geofences fetched afresh.
|
|
12
|
+
func consentGiven(_ core: EngineCore) {
|
|
13
|
+
resume(core)
|
|
14
|
+
if core.locationActive { checkGeofences(force: true) }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// The user opted out: nothing more runs, what's queued goes, and the server is told once.
|
|
18
|
+
func optOut(_ core: EngineCore) {
|
|
19
|
+
Task {
|
|
20
|
+
await runner.cancelAll()
|
|
21
|
+
do {
|
|
22
|
+
if try await core.optOut() { submit("privacy.withdrawn") { await $0.consentWithdrawn() } }
|
|
23
|
+
} catch {
|
|
24
|
+
BubblLog.warning("Bubbl.optOut can't be saved until the device is unlocked: ask again then")
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// The user asked for their data to be erased: stopped now, erased (server, then here) as work.
|
|
30
|
+
func erase(_ core: EngineCore) {
|
|
31
|
+
Task {
|
|
32
|
+
await runner.cancelAll()
|
|
33
|
+
do {
|
|
34
|
+
try await core.requestErasure()
|
|
35
|
+
submit("privacy.erase") { await $0.eraseDevice() }
|
|
36
|
+
} catch {
|
|
37
|
+
BubblLog.warning("Bubbl.deleteMyData can't be saved until the device is unlocked: ask again then")
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Location (geofences) on or off; the rest carries on.
|
|
43
|
+
func setLocationEnabled(_ enabled: Bool, _ core: EngineCore) {
|
|
44
|
+
Task {
|
|
45
|
+
do {
|
|
46
|
+
guard try await core.setLocationEnabled(enabled), enabled else { return }
|
|
47
|
+
checkGeofences(force: true)
|
|
48
|
+
appOpenedForLocation()
|
|
49
|
+
} catch {
|
|
50
|
+
BubblLog.warning("Bubbl.setLocationEnabled can't be saved until the device is unlocked: ask again then")
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// At every start: an erasure or an opt-out a killed app didn't finish telling the server about
|
|
56
|
+
/// carries on (the work runner isn't kept between launches).
|
|
57
|
+
func resumePrivacyWork(_ core: EngineCore) {
|
|
58
|
+
guard let state = core.privacy.state else { return }
|
|
59
|
+
if state.pendingDelete {
|
|
60
|
+
submit("privacy.erase") { await $0.eraseDevice() }
|
|
61
|
+
} else if state.consent == false {
|
|
62
|
+
submit("privacy.withdrawn") { await $0.consentWithdrawn() }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
#if canImport(UserNotifications)
|
|
3
|
+
import UserNotifications
|
|
4
|
+
#endif
|
|
5
|
+
#if !COCOAPODS
|
|
6
|
+
import BubblCore
|
|
7
|
+
#endif
|
|
8
|
+
|
|
9
|
+
/// Shows a notification inside the app: Bubbl's notification screen (M2 slice 10), or the app's
|
|
10
|
+
/// own listener. True when it was shown there, so the system's banner isn't needed as well.
|
|
11
|
+
@available(iOS 17, *)
|
|
12
|
+
protocol NotificationPresenting: Sendable {
|
|
13
|
+
func present(_ notification: JSONValue, opened: Bool) async -> Bool
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// Until the notification screen (slice 10): nothing is shown in the app, so a push in front keeps
|
|
17
|
+
/// the system's banner, and a tap just opens the app.
|
|
18
|
+
@available(iOS 17, *)
|
|
19
|
+
struct SystemAlertPresenter: NotificationPresenting {
|
|
20
|
+
func present(_ notification: JSONValue, opened: Bool) async -> Bool {
|
|
21
|
+
BubblLog.debug("A notification to show\(opened ? " (tapped)" : "")")
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// A value the compiler can't see is safe to hand across: the completion handlers iOS passes the
|
|
27
|
+
/// notification delegate, which are called exactly once, from any thread.
|
|
28
|
+
@available(iOS 17, *)
|
|
29
|
+
struct Handed<Value>: @unchecked Sendable {
|
|
30
|
+
let value: Value
|
|
31
|
+
init(_ value: Value) { self.value = value }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Push in the host: the APNs device token, and Bubbl's pushes as iOS hands them to the app (with
|
|
35
|
+
/// the app in front, and when their notification is tapped). The automatic integration
|
|
36
|
+
/// (PushIntegration) and the manual hooks on Bubbl both come here.
|
|
37
|
+
@available(iOS 17, *)
|
|
38
|
+
extension EngineHost {
|
|
39
|
+
/// The APNs device token iOS gave the app: kept with its environment, and sent with PUT /device
|
|
40
|
+
/// when it's new or changed.
|
|
41
|
+
func pushTokenReceived(_ deviceToken: Data) {
|
|
42
|
+
let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
|
|
43
|
+
let token = PushToken(token: hex, type: "apns", environment: Self.apnsEnvironment)
|
|
44
|
+
guard platform.pushToken() != token else { return }
|
|
45
|
+
platform.setPushToken(token)
|
|
46
|
+
BubblLog.info("Got the device's APNs token (\(token.environment ?? "unknown environment"))")
|
|
47
|
+
submit("device") { await $0.syncDevice() }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Which APNs environment this build's tokens belong to: the provisioning profile's
|
|
51
|
+
/// aps-environment; no profile (the App Store's own signing) is production; the Simulator's
|
|
52
|
+
/// tokens are sandbox ones.
|
|
53
|
+
static let apnsEnvironment: String = {
|
|
54
|
+
#if targetEnvironment(simulator)
|
|
55
|
+
return "sandbox"
|
|
56
|
+
#else
|
|
57
|
+
guard let url = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"),
|
|
58
|
+
let profile = try? Data(contentsOf: url)
|
|
59
|
+
else { return "production" }
|
|
60
|
+
return ProvisioningProfile.apnsEnvironment(profile) ?? "production"
|
|
61
|
+
#endif
|
|
62
|
+
}()
|
|
63
|
+
|
|
64
|
+
/// A push's payload as JSON: its top level, where Bubbl's data sits beside aps. Values that
|
|
65
|
+
/// aren't JSON are left out.
|
|
66
|
+
static func pushData(_ userInfo: [AnyHashable: Any]) -> [String: JSONValue] {
|
|
67
|
+
var data: [String: JSONValue] = [:]
|
|
68
|
+
for (key, value) in userInfo {
|
|
69
|
+
if let key = key as? String, let json = JSONValue(app: value) { data[key] = json }
|
|
70
|
+
}
|
|
71
|
+
return data
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// A notification to show now: `received` (it has just arrived for the first time) tells the
|
|
75
|
+
/// app's event listeners; the app's notification listener may take it (then Bubbl shows
|
|
76
|
+
/// nothing); otherwise Bubbl's screen. True when it was shown in the app.
|
|
77
|
+
func show(_ json: JSONValue, received: Bool, opened: Bool) async -> Bool {
|
|
78
|
+
guard let notification = BubblNotification(json) else { return false }
|
|
79
|
+
let message = BubblMessage(notification)
|
|
80
|
+
if received { BubblEvents.shared.emit(.notificationReceived(message)) }
|
|
81
|
+
if let listener = notificationListener, await MainActor.run(body: { listener(message) }) { return true }
|
|
82
|
+
return await presenter.present(json, opened: opened)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Notifications a geofence or a pull brought, each shown the first time it arrives.
|
|
86
|
+
func arrived(_ notifications: [JSONValue], _ core: EngineCore) async {
|
|
87
|
+
for notification in notifications {
|
|
88
|
+
if case .show(let json, let received) = await core.notificationArrived(notification, opened: false) {
|
|
89
|
+
_ = await show(json, received: received, opened: false)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/// The events of a notification's life, recorded for Bubbl and told to the app's listeners.
|
|
95
|
+
func notificationEvents(_ core: EngineCore) -> NotificationEvents {
|
|
96
|
+
NotificationEvents(
|
|
97
|
+
observe: { type, notification in
|
|
98
|
+
BubblEvents.event(type, BubblMessage(notification)).map(BubblEvents.shared.emit)
|
|
99
|
+
},
|
|
100
|
+
enqueue: { type, data in try await core.events.enqueue(type, data: data) }
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#if canImport(UserNotifications)
|
|
105
|
+
/// The system banner, list entry and sound: how iOS shows a push in front when asked to.
|
|
106
|
+
static let systemPresentation: UNNotificationPresentationOptions = [.banner, .list, .sound]
|
|
107
|
+
|
|
108
|
+
/// A Bubbl push arrived with the app in front: how iOS should show it. Bubbl's screen shows a
|
|
109
|
+
/// campaign's notification when it can (then no banner); a test push is a plain banner; a
|
|
110
|
+
/// notification already shown, or anything while Bubbl isn't active, isn't shown again.
|
|
111
|
+
func presentation(forArriving data: [String: JSONValue]) async -> UNNotificationPresentationOptions {
|
|
112
|
+
// Started later in this launch (a wrapper's JavaScript or Dart not run yet): the system
|
|
113
|
+
// shows it, rather than swallowing a push.
|
|
114
|
+
guard let core = current else { return Self.systemPresentation }
|
|
115
|
+
|
|
116
|
+
switch await core.receivedPush(data, opened: false) {
|
|
117
|
+
case .show(let notification, let received):
|
|
118
|
+
return await show(notification, received: received, opened: false) ? [] : Self.systemPresentation
|
|
119
|
+
case .test:
|
|
120
|
+
return Self.systemPresentation
|
|
121
|
+
case .fetchFailed:
|
|
122
|
+
// The alert has its title and body; a tap fetches it again.
|
|
123
|
+
return Self.systemPresentation
|
|
124
|
+
case .nothing:
|
|
125
|
+
return []
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
#endif
|
|
129
|
+
|
|
130
|
+
/// A Bubbl push's notification was tapped: its notification opens (fetched first if it came
|
|
131
|
+
/// without it; retried with backoff when that fails). A test push just opens the app.
|
|
132
|
+
func pushOpened(_ data: [String: JSONValue]) async {
|
|
133
|
+
guard let core = current else { return }
|
|
134
|
+
|
|
135
|
+
switch await core.receivedPush(data, opened: true) {
|
|
136
|
+
case .show(let notification, let received):
|
|
137
|
+
_ = await show(notification, received: received, opened: true)
|
|
138
|
+
case .fetchFailed(let id, _):
|
|
139
|
+
submit("notifications.fetch.\(id)") { core in
|
|
140
|
+
switch await core.receivedPush(data, opened: true) {
|
|
141
|
+
case .show(let notification, let received):
|
|
142
|
+
_ = await self.show(notification, received: received, opened: true)
|
|
143
|
+
return .done
|
|
144
|
+
case .fetchFailed(_, let failure):
|
|
145
|
+
return await core.handle(failure, "Fetching a notification")
|
|
146
|
+
case .test, .nothing:
|
|
147
|
+
return .done
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
case .test, .nothing:
|
|
151
|
+
break
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|