@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,418 @@
1
+ #if os(iOS)
2
+ import CoreLocation
3
+ import Foundation
4
+ #if !COCOAPODS
5
+ import BubblCore
6
+ #endif
7
+
8
+ /// What the user allowed, as far as geofences go.
9
+ @available(iOS 17, *)
10
+ enum LocationAccess: Sendable, Equatable {
11
+ /// No location, or only approximate (iOS doesn't monitor conditions without precise location,
12
+ /// and an approximate fix can't place the device in or out of a geofence).
13
+ case none
14
+ /// While the app is in use: geofences are checked from fixes while it's open.
15
+ case whenInUse
16
+ /// Always: iOS watches the geofences and relaunches the app for them.
17
+ case always
18
+ }
19
+
20
+ /// CoreLocation for the engine, as Android's PlayServicesRegionMonitor and receivers:
21
+ /// - Bubbl's circles (named "bubbl.…") watched as CLMonitor conditions (ConditionWatcher), plus
22
+ /// significant location changes; both relaunch the app in the background, and neither needs
23
+ /// the location background mode. iOS's 20 are shared with the app and other SDKs: what Bubbl
24
+ /// asks for is lowered when iOS refuses some as over the limit (RegionBudget);
25
+ /// - one-off fixes (for a check, or a polygon), and fixes while the app is open when iOS isn't
26
+ /// watching ("While Using" only, or no condition monitoring on the device), from which the
27
+ /// engine decides entering and leaving itself;
28
+ /// - on iOS 18+, a CLServiceSession for "Always" while geofences are watched, so their events
29
+ /// arrive in the background.
30
+ /// Created on the main thread at start, so events that relaunched the app are delivered to it.
31
+ @available(iOS 17, *)
32
+ @MainActor
33
+ final class LocationService: NSObject, CLLocationManagerDelegate {
34
+ static let shared = LocationService()
35
+
36
+ /// Significant location changes and permission; also sees the app's own regions (the older
37
+ /// API's), which count against the 20.
38
+ private let changes = CLLocationManager()
39
+ /// One-off fixes.
40
+ private let oneShot = CLLocationManager()
41
+ /// Fixes while the app is open and iOS isn't watching the geofences.
42
+ private let inUse = CLLocationManager()
43
+ private var waiting: [CheckedContinuation<Fix?, Never>] = []
44
+ /// Which request for a fix is outstanding, so an earlier one's timeout can't end a later one.
45
+ private var fixRequest = 0
46
+ private var updatingInUse = false
47
+ private var saidWhy: Set<String> = []
48
+ /// The access last seen, so only a change is acted on (iOS reports it once at every launch too).
49
+ private var knownAccess: LocationAccess?
50
+ private var budget = RegionBudget()
51
+ /// iOS can't monitor Bubbl's conditions on this device (none of them, or not the refresh one).
52
+ private var cantWatch = false
53
+ /// iOS 18+: the CLServiceSession held while geofences are watched.
54
+ private var session: AnyObject?
55
+
56
+ private static let fixTimeoutSeconds: UInt64 = 20
57
+
58
+ override private init() {
59
+ super.init()
60
+ for manager in [changes, oneShot, inUse] { manager.delegate = self }
61
+ inUse.desiredAccuracy = kCLLocationAccuracyHundredMeters
62
+ inUse.distanceFilter = 20
63
+ inUse.pausesLocationUpdatesAutomatically = true
64
+ }
65
+
66
+ /// Makes sure the managers exist, the service session is held and CLMonitor's events are
67
+ /// listened to: an app relaunched for a geofence gets the event only then.
68
+ func activate() {
69
+ // Regions an older build watched with the region API, which Bubbl no longer uses.
70
+ for region in changes.monitoredRegions where region.identifier.hasPrefix(GeofenceEngine.regionPrefix) {
71
+ changes.stopMonitoring(for: region)
72
+ }
73
+ holdSession()
74
+ Task { await ConditionWatcher.shared.listen() }
75
+ }
76
+
77
+ var access: LocationAccess {
78
+ switch changes.authorizationStatus {
79
+ case .authorizedAlways where changes.accuracyAuthorization == .fullAccuracy: return .always
80
+ case .authorizedWhenInUse where changes.accuracyAuthorization == .fullAccuracy: return .whenInUse
81
+ case .authorizedAlways, .authorizedWhenInUse:
82
+ explain("Precise location is off for this app: geofences need it")
83
+ return .none
84
+ default: return .none
85
+ }
86
+ }
87
+
88
+ /// iOS watches the geofences. Otherwise entering and leaving are decided from fixes: while the
89
+ /// app is open, and with "Always" from significant location changes too, which still relaunch
90
+ /// the app (Android's approach when the OS won't watch).
91
+ var osWatches: Bool { access == .always && !cantWatch }
92
+
93
+ /// The freshest fix CoreLocation has, if any.
94
+ var lastFix: Fix? {
95
+ [oneShot.location, inUse.location, changes.location].compactMap { $0 }.max { $0.timestamp < $1.timestamp }.map(Fix.init)
96
+ }
97
+
98
+ // MARK: - RegionMonitor
99
+
100
+ /// iOS's 20 less the app's own regions (those it can see), within what iOS has let Bubbl have.
101
+ func capacity() -> Int {
102
+ guard !cantWatch else { return 0 }
103
+ let others = changes.monitoredRegions.filter { !$0.identifier.hasPrefix(GeofenceEngine.regionPrefix) }.count
104
+ return budget.capacity(othersVisible: others)
105
+ }
106
+
107
+ /// Watch exactly `wanted` with "Always" (otherwise nothing: fixes decide while the app is
108
+ /// open), each radius between 100 m (iOS is unreliable below) and iOS's maximum.
109
+ func watch(_ wanted: [WatchedRegion]) async -> Bool {
110
+ guard access == .always else {
111
+ await stopWatching()
112
+ if access == .whenInUse { explain("Location only while in use: geofences are checked while the app is open") }
113
+ return true
114
+ }
115
+ holdSession()
116
+ changes.startMonitoringSignificantLocationChanges()
117
+ guard !cantWatch else {
118
+ // Nothing iOS can watch here: significant changes bring the fixes that decide.
119
+ await ConditionWatcher.shared.removeAll()
120
+ return true
121
+ }
122
+
123
+ let limit = changes.maximumRegionMonitoringDistance
124
+ let regions = wanted.map { WatchedRegion($0.id, $0.center, Self.radius($0.radiusMeters, limit: limit)) }
125
+ budget.watching(regions.map(\.id))
126
+ await ConditionWatcher.shared.watch(regions)
127
+ BubblLog.debug("Watching \(regions.count) geofence conditions")
128
+ return true
129
+ }
130
+
131
+ static func radius(_ meters: Double, limit: CLLocationDistance) -> Double {
132
+ let capped = limit > 0 ? min(meters, limit) : meters
133
+ return max(GeofenceEngine.minWatchRadiusMeters, capped)
134
+ }
135
+
136
+ func stopWatching() async {
137
+ await ConditionWatcher.shared.removeAll()
138
+ changes.stopMonitoringSignificantLocationChanges()
139
+ releaseSession()
140
+ }
141
+
142
+ func stopAll() async {
143
+ stopInUseUpdates()
144
+ await stopWatching()
145
+ }
146
+
147
+ // MARK: - What CLMonitor reports
148
+
149
+ /// iOS isn't watching `id` because the app is over its 20: ask for fewer from now on.
150
+ func overLimit(_ id: String) {
151
+ guard budget.overLimit(id) else { return }
152
+ explain("iOS's 20 geofences are shared with the app and other SDKs: Bubbl watches fewer (\(budget.cap) now)", key: "overLimit")
153
+ EngineHost.shared.watchFailed()
154
+ EngineHost.shared.checkGeofences(rewatch: true)
155
+ }
156
+
157
+ /// Bubbl's conditions iOS can't monitor here: the engine picks the next nearest instead.
158
+ func excluded() -> Set<String> { budget.excluded }
159
+
160
+ /// iOS can't monitor `id` at all: that one is left out (for good, and the next nearest is
161
+ /// watched in its place), unless none can be watched here.
162
+ func unsupported(_ id: String) {
163
+ switch budget.unsupported(id, refreshRegionId: GeofenceEngine.refreshRegionId) {
164
+ case nil:
165
+ return
166
+ case .dropOne:
167
+ BubblLog.warning("iOS can't watch one geofence: another is watched in its place")
168
+ Task { await ConditionWatcher.shared.remove(id) }
169
+ EngineHost.shared.watchFailed()
170
+ EngineHost.shared.checkGeofences(rewatch: true)
171
+ case .deviceCantWatch:
172
+ guard !cantWatch else { return }
173
+ cantWatch = true
174
+ explain("iOS can't watch geofences on this device: they're checked from location changes")
175
+ EngineHost.shared.checkGeofences(rewatch: true)
176
+ }
177
+ }
178
+
179
+ /// iOS stopped watching `id` for another reason (`why`, on iOS 18+): watched again at the next check.
180
+ func unmonitored(_ id: String, why: String) {
181
+ BubblLog.warning("CoreLocation isn't watching a geofence\(why)")
182
+ EngineHost.shared.watchFailed()
183
+ }
184
+
185
+ // MARK: - Fixes
186
+
187
+ /// A fix now: the last one if recent enough (30 s and within 100 m when `precise`, for a
188
+ /// polygon; 5 min otherwise), else a fresh one. Nil without permission or within 20 s.
189
+ func currentFix(precise: Bool) async -> Fix? {
190
+ guard access != .none else { return nil }
191
+ let maxAge: TimeInterval = precise ? 30 : 300
192
+ for candidate in [oneShot.location, inUse.location, changes.location].compactMap({ $0 }) {
193
+ if -candidate.timestamp.timeIntervalSinceNow <= maxAge && (!precise || (0...GeofenceEngine.maxPolygonAccuracyMeters).contains(candidate.horizontalAccuracy)) {
194
+ return Fix(candidate)
195
+ }
196
+ }
197
+
198
+ oneShot.desiredAccuracy = precise ? kCLLocationAccuracyBest : kCLLocationAccuracyHundredMeters
199
+ return await withCheckedContinuation { continuation in
200
+ waiting.append(continuation)
201
+ guard waiting.count == 1 else { return }
202
+ fixRequest += 1
203
+ let request = fixRequest
204
+ oneShot.requestLocation()
205
+ Task { @MainActor in
206
+ try? await Task.sleep(nanoseconds: Self.fixTimeoutSeconds * 1_000_000_000)
207
+ if self.fixRequest == request { self.deliver(nil) }
208
+ }
209
+ }
210
+ }
211
+
212
+ /// While the app is open and iOS isn't watching the geofences: fixes every 20 m or so.
213
+ func startInUseUpdates() {
214
+ guard access != .none, !osWatches, !updatingInUse else { return }
215
+ updatingInUse = true
216
+ inUse.startUpdatingLocation()
217
+ }
218
+
219
+ func stopInUseUpdates() {
220
+ guard updatingInUse else { return }
221
+ updatingInUse = false
222
+ inUse.stopUpdatingLocation()
223
+ }
224
+
225
+ // MARK: - CLLocationManagerDelegate (on the main thread, where the managers were made)
226
+
227
+ // Only Sendable values cross into the main actor: a Fix, the manager's identity.
228
+
229
+ nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
230
+ guard let fix = locations.last.map(Fix.init) else { return }
231
+ let from = ObjectIdentifier(manager)
232
+ MainActor.assumeIsolated {
233
+ if from == ObjectIdentifier(oneShot) {
234
+ deliver(fix)
235
+ } else if from == ObjectIdentifier(inUse) {
236
+ EngineHost.shared.locationFix(fix, osWatches: false)
237
+ } else {
238
+ // A significant change: fresher polygons, and whether to pick or fetch again (or,
239
+ // where iOS can't watch, whether a geofence was entered or left).
240
+ EngineHost.shared.locationFix(fix, osWatches: osWatches)
241
+ }
242
+ }
243
+ }
244
+
245
+ nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: any Error) {
246
+ let from = ObjectIdentifier(manager)
247
+ MainActor.assumeIsolated {
248
+ if from == ObjectIdentifier(oneShot) { deliver(nil) }
249
+ }
250
+ }
251
+
252
+ nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
253
+ let from = ObjectIdentifier(manager)
254
+ MainActor.assumeIsolated {
255
+ guard from == ObjectIdentifier(changes) else { return }
256
+ let now = access
257
+ defer { knownAccess = now }
258
+ guard let before = knownAccess, before != now else { return }
259
+ if now != .always { releaseSession() }
260
+ if now == .none || osWatches { stopInUseUpdates() }
261
+ EngineHost.shared.locationAccessChanged(now)
262
+ }
263
+ }
264
+
265
+ // MARK: -
266
+
267
+ /// iOS 18+: "Always" declared for as long as geofences are watched. Only once it's granted:
268
+ /// a session asking for it before then would put up the system prompt.
269
+ private func holdSession() {
270
+ guard #available(iOS 18, *), session == nil, access == .always else { return }
271
+ session = CLServiceSession(authorization: .always)
272
+ }
273
+
274
+ private func releaseSession() {
275
+ guard #available(iOS 18, *), let held = session as? CLServiceSession else { return }
276
+ held.invalidate()
277
+ session = nil
278
+ }
279
+
280
+ private func deliver(_ fix: Fix?) {
281
+ fixRequest += 1
282
+ let continuations = waiting
283
+ waiting.removeAll()
284
+ continuations.forEach { $0.resume(returning: fix) }
285
+ }
286
+
287
+ /// Says why geofences are limited, once per reason.
288
+ private func explain(_ message: String, key: String? = nil) {
289
+ guard saidWhy.insert(key ?? message).inserted else { return }
290
+ BubblLog.info(message)
291
+ }
292
+
293
+ nonisolated static func nowMillis() -> Int64 { Int64(Date().timeIntervalSince1970 * 1000) }
294
+ }
295
+
296
+ /// CLMonitor for Bubbl's circles, one monitor named for the SDK. Its conditions persist across
297
+ /// launches; its events are listened to from start, as iOS relaunches the app to deliver them. A
298
+ /// condition added "assuming unsatisfied" reports satisfied at once if the device is already
299
+ /// inside, so nothing needs to ask for its state.
300
+ @available(iOS 17, *)
301
+ actor ConditionWatcher {
302
+ static let shared = ConditionWatcher()
303
+ /// Letters and digits only: CLMonitor throws (crashing the app) for a name like "tech.bubbl.sdk".
304
+ private static let name = "BubblSDK"
305
+ private var monitor: CLMonitor?
306
+
307
+ /// The monitor, made and listened to on first use.
308
+ private func current() async -> CLMonitor {
309
+ if let monitor { return monitor }
310
+ let created = await CLMonitor(Self.name)
311
+ monitor = created
312
+ Task { await Self.deliver(created) }
313
+ return created
314
+ }
315
+
316
+ func listen() async { _ = await current() }
317
+
318
+ func watch(_ wanted: [WatchedRegion]) async {
319
+ let monitor = await current()
320
+ let ids = Set(wanted.map(\.id))
321
+ for id in await monitor.identifiers where id.hasPrefix(GeofenceEngine.regionPrefix) && !ids.contains(id) {
322
+ await monitor.remove(id)
323
+ }
324
+ for region in wanted {
325
+ let center = CLLocationCoordinate2D(latitude: region.center.latitude, longitude: region.center.longitude)
326
+ // Unchanged: left alone, so it isn't reported again.
327
+ if let existing = await monitor.record(for: region.id)?.condition as? CLMonitor.CircularGeographicCondition,
328
+ abs(existing.center.latitude - center.latitude) < 1e-7, abs(existing.center.longitude - center.longitude) < 1e-7,
329
+ abs(existing.radius - region.radiusMeters) < 0.01 {
330
+ continue
331
+ }
332
+ await monitor.add(CLMonitor.CircularGeographicCondition(center: center, radius: region.radiusMeters), identifier: region.id, assuming: .unsatisfied)
333
+ }
334
+ }
335
+
336
+ func remove(_ id: String) async {
337
+ await current().remove(id)
338
+ }
339
+
340
+ func removeAll() async {
341
+ let monitor = await current()
342
+ for id in await monitor.identifiers where id.hasPrefix(GeofenceEngine.regionPrefix) { await monitor.remove(id) }
343
+ }
344
+
345
+ private enum Unmonitored: Sendable {
346
+ case overLimit, unsupported, other(String)
347
+ }
348
+
349
+ private static func deliver(_ monitor: CLMonitor) async {
350
+ do {
351
+ for try await event in await monitor.events {
352
+ let id = event.identifier
353
+ guard id.hasPrefix(GeofenceEngine.regionPrefix) else { continue }
354
+ let millis = Int64(event.date.timeIntervalSince1970 * 1000)
355
+ switch event.state {
356
+ case .satisfied, .unsatisfied:
357
+ let entered = event.state == .satisfied
358
+ await MainActor.run {
359
+ EngineHost.shared.regionEvent([id], entered: entered, fix: LocationService.shared.lastFix, deviceTimeMillis: millis)
360
+ }
361
+ case .unmonitored:
362
+ let reason = unmonitored(event)
363
+ await MainActor.run {
364
+ switch reason {
365
+ case .overLimit: LocationService.shared.overLimit(id)
366
+ case .unsupported: LocationService.shared.unsupported(id)
367
+ case .other(let why): LocationService.shared.unmonitored(id, why: why)
368
+ }
369
+ }
370
+ default:
371
+ continue
372
+ }
373
+ }
374
+ } catch {
375
+ BubblLog.warning("CoreLocation's geofence events ended: \(type(of: error))")
376
+ }
377
+ }
378
+
379
+ /// Why a condition isn't monitored, as far as iOS 18+ says.
380
+ private static func unmonitored(_ event: CLMonitor.Event) -> Unmonitored {
381
+ guard #available(iOS 18, *) else { return .other("") }
382
+ if event.conditionLimitExceeded { return .overLimit }
383
+ if event.conditionUnsupported { return .unsupported }
384
+ let reasons: [(Bool, String)] = [
385
+ (event.authorizationDenied, "location denied"),
386
+ (event.authorizationDeniedGlobally, "location services off"),
387
+ (event.authorizationRestricted, "location restricted"),
388
+ (event.insufficientlyInUse, "not in use and no Always"),
389
+ (event.accuracyLimited, "approximate location"),
390
+ (event.persistenceUnavailable, "persistence unavailable"),
391
+ (event.serviceSessionRequired, "service session required"),
392
+ (event.authorizationRequestInProgress, "permission prompt showing"),
393
+ ]
394
+ let found = reasons.filter(\.0).map(\.1)
395
+ return .other(found.isEmpty ? "" : ": " + found.joined(separator: ", "))
396
+ }
397
+ }
398
+
399
+ /// The engine's RegionMonitor, on LocationService.
400
+ @available(iOS 17, *)
401
+ struct CoreLocationRegionMonitor: RegionMonitor {
402
+ func capacity() async -> Int { await LocationService.shared.capacity() }
403
+ func watch(_ regions: [WatchedRegion]) async -> Bool { await LocationService.shared.watch(regions) }
404
+ func stop() async { await LocationService.shared.stopAll() }
405
+ func excluded() async -> Set<String> { await LocationService.shared.excluded() }
406
+ }
407
+
408
+ @available(iOS 17, *)
409
+ extension Fix {
410
+ init(_ location: CLLocation) {
411
+ self.init(
412
+ LatLng(location.coordinate.latitude, location.coordinate.longitude),
413
+ accuracyMeters: location.horizontalAccuracy >= 0 ? location.horizontalAccuracy : nil,
414
+ timeMillis: Int64(location.timestamp.timeIntervalSince1970 * 1000)
415
+ )
416
+ }
417
+ }
418
+ #endif