@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.
Files changed (176) hide show
  1. package/BubblReactNativeSdk.podspec +29 -9
  2. package/CHANGELOG.md +52 -1
  3. package/LICENSE +202 -1
  4. package/README.md +172 -83
  5. package/android/build.gradle +33 -30
  6. package/android/src/main/AndroidManifest.xml +1 -0
  7. package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
  8. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
  9. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
  10. package/app.plugin.js +2 -0
  11. package/ios/BubblPush.swift +44 -0
  12. package/ios/BubblSdk.h +7 -0
  13. package/ios/BubblSdk.mm +252 -0
  14. package/ios/BubblSdkImpl.swift +335 -0
  15. package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
  16. package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
  17. package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
  18. package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
  19. package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
  20. package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
  21. package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
  22. package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
  23. package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
  24. package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
  25. package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
  26. package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
  27. package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
  28. package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
  29. package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
  30. package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
  31. package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
  32. package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
  33. package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
  34. package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
  35. package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
  36. package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  37. package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
  38. package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
  39. package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
  40. package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
  41. package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
  42. package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
  43. package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
  44. package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
  45. package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
  46. package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
  47. package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
  48. package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
  49. package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
  50. package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
  51. package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
  52. package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
  53. package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
  54. package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
  55. package/ios-sdk/BubblCore/API/Http.swift +39 -0
  56. package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
  57. package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
  58. package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
  59. package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
  60. package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
  61. package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
  62. package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
  63. package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
  64. package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
  65. package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
  66. package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
  67. package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
  68. package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
  69. package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
  70. package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
  71. package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  72. package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
  73. package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
  74. package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
  75. package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
  76. package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
  77. package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
  78. package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
  79. package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
  80. package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
  81. package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
  82. package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
  83. package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
  84. package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
  85. package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
  86. package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
  87. package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
  88. package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
  89. package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
  90. package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
  91. package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
  92. package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
  93. package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
  94. package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
  95. package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
  96. package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
  97. package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
  98. package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
  99. package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
  100. package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
  101. package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
  102. package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
  103. package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
  104. package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
  105. package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
  106. package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
  107. package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
  108. package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
  109. package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
  110. package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
  111. package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
  112. package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
  113. package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
  114. package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
  115. package/jest/index.js +75 -0
  116. package/lib/commonjs/BubblModule.js +14 -0
  117. package/lib/commonjs/BubblModule.js.map +1 -0
  118. package/lib/commonjs/BubblModule.web.js +13 -0
  119. package/lib/commonjs/BubblModule.web.js.map +1 -0
  120. package/lib/commonjs/NativeBubblSdk.js +20 -0
  121. package/lib/commonjs/NativeBubblSdk.js.map +1 -0
  122. package/lib/commonjs/index.js +336 -0
  123. package/lib/commonjs/index.js.map +1 -0
  124. package/lib/commonjs/package.json +1 -0
  125. package/lib/commonjs/types.js +2 -0
  126. package/lib/commonjs/types.js.map +1 -0
  127. package/lib/module/BubblModule.js +5 -0
  128. package/lib/module/BubblModule.js.map +1 -0
  129. package/lib/module/BubblModule.web.js +9 -0
  130. package/lib/module/BubblModule.web.js.map +1 -0
  131. package/lib/module/NativeBubblSdk.js +18 -0
  132. package/lib/module/NativeBubblSdk.js.map +1 -0
  133. package/lib/module/index.js +331 -0
  134. package/lib/module/index.js.map +1 -0
  135. package/lib/module/package.json +1 -0
  136. package/lib/module/types.js +2 -0
  137. package/lib/module/types.js.map +1 -0
  138. package/lib/typescript/commonjs/package.json +1 -0
  139. package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
  140. package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
  142. package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
  143. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
  144. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
  145. package/lib/typescript/commonjs/src/index.d.ts +111 -0
  146. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  147. package/lib/typescript/commonjs/src/types.d.ts +192 -0
  148. package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
  149. package/lib/typescript/module/package.json +1 -0
  150. package/lib/typescript/module/src/BubblModule.d.ts +3 -0
  151. package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
  152. package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
  153. package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
  154. package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
  155. package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
  156. package/lib/typescript/module/src/index.d.ts +111 -0
  157. package/lib/typescript/module/src/index.d.ts.map +1 -0
  158. package/lib/typescript/module/src/types.d.ts +192 -0
  159. package/lib/typescript/module/src/types.d.ts.map +1 -0
  160. package/package.json +105 -24
  161. package/plugin/build/imageNotifications.d.ts +35 -0
  162. package/plugin/build/imageNotifications.js +198 -0
  163. package/plugin/build/index.d.ts +84 -0
  164. package/plugin/build/index.js +176 -0
  165. package/src/BubblModule.ts +2 -0
  166. package/src/BubblModule.web.ts +9 -0
  167. package/src/NativeBubblSdk.ts +67 -0
  168. package/src/index.ts +338 -282
  169. package/src/types.ts +182 -0
  170. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
  171. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
  172. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
  173. package/ios/BubblSdk.swift +0 -802
  174. package/ios/BubblSdkBridge.m +0 -86
  175. package/react-native.config.js +0 -11
  176. 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
+ }