@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,488 @@
1
+ import Foundation
2
+
3
+ /// A circle for the OS to watch, for both entering and leaving.
4
+ package struct WatchedRegion: Sendable, Hashable {
5
+ package let id: String
6
+ package let center: LatLng
7
+ package let radiusMeters: Double
8
+
9
+ package init(_ id: String, _ center: LatLng, _ radiusMeters: Double) {
10
+ self.id = id
11
+ self.center = center
12
+ self.radiusMeters = radiusMeters
13
+ }
14
+ }
15
+
16
+ /// The OS's region monitoring (CoreLocation on iOS).
17
+ package protocol RegionMonitor: Sendable {
18
+ /// How many regions Bubbl may watch now: the OS's limit less what the app itself and other
19
+ /// SDKs watch (iOS allows 20 per app, all told).
20
+ func capacity() async -> Int
21
+
22
+ /// Watch exactly `regions`, replacing whatever Bubbl watched before. True once they're being
23
+ /// watched (or deliberately aren't: location only while in use); false when the OS refused.
24
+ func watch(_ regions: [WatchedRegion]) async -> Bool
25
+
26
+ func stop() async
27
+
28
+ /// Bubbl's region names the OS can't watch on this device, so others are picked in their place.
29
+ func excluded() async -> Set<String>
30
+ }
31
+
32
+ extension RegionMonitor {
33
+ package func excluded() async -> Set<String> { [] }
34
+ }
35
+
36
+ /// The device entered or left a geofence: to send to POST /geofence-events.
37
+ package struct Transition: Sendable, Hashable, Codable {
38
+ /// Sent as Idempotency-Key, so a retry gets the first answer instead of counting twice.
39
+ package let key: String
40
+ package let locationId: String
41
+ package let enter: Bool
42
+ /// On the server's clock.
43
+ package let occurredAtMillis: Int64
44
+ package let fix: Fix?
45
+
46
+ package init(key: String, locationId: String, enter: Bool, occurredAtMillis: Int64, fix: Fix?) {
47
+ self.key = key
48
+ self.locationId = locationId
49
+ self.enter = enter
50
+ self.occurredAtMillis = occurredAtMillis
51
+ self.fix = fix
52
+ }
53
+ }
54
+
55
+ package enum RefreshResult: Sendable, Equatable {
56
+ /// A new set of geofences is being watched.
57
+ case updated(geofences: Int)
58
+ /// The server's set hadn't changed (304); watching goes on from the new position.
59
+ case unchanged
60
+ /// Not time to ask again yet (the nearest geofences may still have been picked afresh).
61
+ case notDue
62
+ case failed(ApiFailure)
63
+ }
64
+
65
+ package enum SendResult: Sendable, Equatable {
66
+ /// The server recorded it; `notifications` are to be shown now (often none).
67
+ case delivered(notifications: [JSONValue])
68
+ /// Given up on: too old to send, or refused for good.
69
+ case dropped(reason: String)
70
+ case failed(ApiFailure)
71
+ }
72
+
73
+ /// What a region event or a location fix led to, for the platform layer to act on.
74
+ package struct Outcome: Sendable, Equatable {
75
+ package var transitions: [Transition] = []
76
+ /// The device has left the area its geofences were picked for: refresh now.
77
+ package var refresh = false
78
+ /// A polygon's circle was entered without a fix good enough to check the polygon: get one.
79
+ package var needsFix = false
80
+
81
+ package init(transitions: [Transition] = [], refresh: Bool = false, needsFix: Bool = false) {
82
+ self.transitions = transitions
83
+ self.refresh = refresh
84
+ self.needsFix = needsFix
85
+ }
86
+ }
87
+
88
+ /// The geofencing logic, free of iOS: which geofences to watch and when to ask for new ones
89
+ /// (GET /geofences), turning the OS's circle events and location fixes into enter and exit
90
+ /// transitions (checking polygons on the device), and sending them (POST /geofence-events). The
91
+ /// same engine as Android's, with iOS's region budget.
92
+ ///
93
+ /// The OS watches the nearest geofences' circles, as many as the budget allows, plus one more
94
+ /// around where the device was (REFRESH_REGION_ID): leaving it means picking again, and asking
95
+ /// the server again once the device is refresh_distance_meters from where it last asked. When
96
+ /// geofences had to be left out, that region stops short of the nearest one left out, so the
97
+ /// device re-picks before it could get there. The server's refresh_seconds covers a device
98
+ /// that stays put.
99
+ ///
100
+ /// Transitions are checked against GeofenceState, so the OS saying "entered" twice is reported
101
+ /// once. Every decision about what to show (cooldowns, trigger limits, quiet hours) is the
102
+ /// server's. Methods throw when the state can't be read yet (GeofenceStateUnavailable).
103
+ package final class GeofenceEngine: Sendable {
104
+ /// Every region Bubbl asks the OS to watch is named with this prefix, so the platform layer
105
+ /// can tell Bubbl's from the app's own and other SDKs' (iOS's 20 are shared).
106
+ package static let regionPrefix = "bubbl."
107
+ package static let refreshRegionId = regionPrefix + "refresh"
108
+ /// A fix vaguer than this can't place the device inside or outside a polygon.
109
+ package static let maxPolygonAccuracyMeters = 100.0
110
+ /// …nor one older than this (iOS reports entering a region without a location, and the last
111
+ /// known one can be minutes old).
112
+ package static let maxPolygonFixAgeMillis: Int64 = 2 * 60 * 1000
113
+ /// …nor a circle, when the SDK decides entering and leaving itself.
114
+ package static let maxCircleAccuracyMeters = 200.0
115
+ /// The server keeps an Idempotency-Key's answer for a day; stop retrying before then.
116
+ package static let maxSendAgeMillis: Int64 = 23 * 60 * 60 * 1000
117
+ /// The smallest refresh region worth watching (iOS is unreliable below about 100 m).
118
+ package static let minWatchRadiusMeters = 100.0
119
+
120
+ private let api: DeviceApiClient
121
+ private let clock: ServerClock
122
+ private let store: any GeofenceStateStore
123
+ private let monitor: any RegionMonitor
124
+ private let newKey: @Sendable () -> String
125
+ private let stateLock = AsyncMutex()
126
+ private let refreshing = AsyncMutex()
127
+
128
+ package init(
129
+ api: DeviceApiClient,
130
+ clock: ServerClock,
131
+ store: any GeofenceStateStore,
132
+ monitor: any RegionMonitor,
133
+ newKey: @escaping @Sendable () -> String = { UUID().uuidString.lowercased() }
134
+ ) {
135
+ self.api = api
136
+ self.clock = clock
137
+ self.store = store
138
+ self.monitor = monitor
139
+ self.newKey = newKey
140
+ }
141
+
142
+ /// Fetch geofences for `fix` if there are none yet, the set is older than its
143
+ /// refresh_seconds, the device has moved refresh_distance_meters from where it asked, or
144
+ /// `force`; then watch the nearest. When a fetch isn't due but the device has left the
145
+ /// region its nearest geofences were picked for, they're picked again around it.
146
+ package func refresh(_ fix: Fix, force: Bool = false) async throws -> RefreshResult {
147
+ try await refreshing.withLock { try await self.refreshLocked(fix, force: force) }
148
+ }
149
+
150
+ private func refreshLocked(_ fix: Fix, force: Bool) async throws -> RefreshResult {
151
+ let saved = try await stateLock.withLock { try self.store.load() }
152
+
153
+ if !force, let current = saved.set, !isDue(current, fix.position) {
154
+ if Self.leftWatchRegion(saved, fix) {
155
+ _ = try await watchSaved(around: fix.position)
156
+ }
157
+ return .notDue
158
+ }
159
+
160
+ let response: ApiResponse
161
+ do {
162
+ response = try await api.request(
163
+ "GET", "api/v1/geofences",
164
+ query: ["latitude": Self.coordinate(fix.position.latitude), "longitude": Self.coordinate(fix.position.longitude)],
165
+ headers: saved.set?.etag.map { ["If-None-Match": $0] } ?? [:]
166
+ )
167
+ } catch {
168
+ return .failed(.backoff)
169
+ }
170
+
171
+ let now = clock.nowSeconds()
172
+ let set: GeofenceSet
173
+ let result: RefreshResult
174
+ if response.status == 304, var current = saved.set {
175
+ current.origin = fix.position
176
+ current.fetchedAtSeconds = now
177
+ set = current
178
+ result = .unchanged
179
+ } else if response.status == 200 {
180
+ guard let fetched = GeofenceSet.fromResponse(Data(response.body.utf8), origin: fix.position, fetchedAtSeconds: now, etag: response.header("ETag")) else {
181
+ return .failed(.backoff)
182
+ }
183
+ set = fetched
184
+ result = .updated(geofences: fetched.geofences.count)
185
+ } else if response.isSuccessful {
186
+ return .failed(.backoff)
187
+ } else {
188
+ return .failed(ApiFailure.of(response))
189
+ }
190
+
191
+ try await stateLock.withLock {
192
+ let state = try self.store.load()
193
+ let ids = Set(set.geofences.map(\.id))
194
+ // Geofences no longer in the set are forgotten, not reported as left.
195
+ try self.store.save(GeofenceState(
196
+ set: set,
197
+ insideCircles: state.insideCircles.intersection(ids),
198
+ insidePolygons: state.insidePolygons.intersection(ids),
199
+ watching: false
200
+ ))
201
+ }
202
+ _ = try await watchSaved(around: fix.position)
203
+ return result
204
+ }
205
+
206
+ /// Watch the saved geofences if the OS hasn't taken them on yet (a watch cut short, or one it
207
+ /// refused). Every check calls this, so geofencing mends itself. True when they're watched.
208
+ package func ensureWatching() async throws -> Bool {
209
+ let state = try await stateLock.withLock { try self.store.load() }
210
+ guard let set = state.set else { return false }
211
+ if state.watching { return true }
212
+ return try await watchSaved(around: state.watchCenter ?? set.origin)
213
+ }
214
+
215
+ /// The OS dropped or refused Bubbl's regions (a reboot, location switched off, iOS's
216
+ /// monitoringDidFailFor): watch them again now.
217
+ package func rewatch() async throws -> Bool {
218
+ try await watchFailed()
219
+ return try await ensureWatching()
220
+ }
221
+
222
+ /// The OS couldn't watch a region: the next check watches everything again.
223
+ package func watchFailed() async throws {
224
+ try await stateLock.withLock {
225
+ var state = try self.store.load()
226
+ state.watching = false
227
+ try self.store.save(state)
228
+ }
229
+ }
230
+
231
+ private func watchSaved(around center: LatLng) async throws -> Bool {
232
+ guard let set = try await stateLock.withLock({ try self.store.load().set }) else { return false }
233
+ let picked = Self.regions(set, around: center, capacity: await monitor.capacity(), excluding: await monitor.excluded())
234
+ let watched = await monitor.watch(picked.regions)
235
+
236
+ try await stateLock.withLock {
237
+ var state = try self.store.load()
238
+ // Only if the set is still the one just watched (a refresh may have replaced it).
239
+ guard state.set == set else { return }
240
+ state.watching = watched
241
+ state.watchCenter = center
242
+ state.watchRadiusMeters = picked.radius
243
+ try self.store.save(state)
244
+ }
245
+ return watched
246
+ }
247
+
248
+ /// The OS region name for a geofence.
249
+ package static func regionId(for geofenceId: String) -> String { regionPrefix + geofenceId }
250
+
251
+ /// The OS says the device entered (or left) the regions `regionIds` (the names Bubbl gave
252
+ /// them; others are ignored), at `deviceTimeMillis`, with the latest fix when there is one.
253
+ package func onRegionEvent(_ regionIds: [String], entered: Bool, fix: Fix?, deviceTimeMillis: Int64) async throws -> Outcome {
254
+ let ids = regionIds.compactMap { id -> String? in
255
+ if id == Self.refreshRegionId { return id }
256
+ guard id.hasPrefix(Self.regionPrefix) else { return nil }
257
+ return String(id.dropFirst(Self.regionPrefix.count))
258
+ }
259
+ return try await transitions(ids, entered: entered, fix: fix, deviceTimeMillis: deviceTimeMillis)
260
+ }
261
+
262
+ /// onRegionEvent with geofence ids (and the refresh region's name) rather than OS names.
263
+ private func transitions(_ regionIds: [String], entered: Bool, fix: Fix?, deviceTimeMillis: Int64) async throws -> Outcome {
264
+ try await stateLock.withLock {
265
+ var state = try self.store.load()
266
+ guard let set = state.set else { return Outcome() }
267
+ let byId = Dictionary(set.geofences.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
268
+ var outcome = Outcome()
269
+
270
+ for id in regionIds {
271
+ if id == Self.refreshRegionId {
272
+ outcome.refresh = outcome.refresh || !entered
273
+ continue
274
+ }
275
+ guard let geofence = byId[id] else { continue }
276
+
277
+ if entered {
278
+ guard !state.insideCircles.contains(id) else { continue }
279
+ state.insideCircles.insert(id)
280
+ if !geofence.isPolygon {
281
+ if geofence.reportsEnter { outcome.transitions.append(self.transition(geofence, enter: true, deviceTimeMillis, fix)) }
282
+ } else if fix.map({ Self.canCheckPolygon($0, at: deviceTimeMillis) }) != true {
283
+ outcome.needsFix = true
284
+ }
285
+ } else {
286
+ guard state.insideCircles.contains(id) else { continue }
287
+ state.insideCircles.remove(id)
288
+ let wasInside = !geofence.isPolygon || state.insidePolygons.contains(id)
289
+ state.insidePolygons.remove(id)
290
+ if wasInside && geofence.reportsExit { outcome.transitions.append(self.transition(geofence, enter: false, deviceTimeMillis, fix)) }
291
+ }
292
+ }
293
+
294
+ // Polygons whose circle the device is in are checked against the fix, if it's good enough.
295
+ if let fix, Self.canCheckPolygon(fix, at: deviceTimeMillis) {
296
+ self.checkPolygons(&state, byId, fix, deviceTimeMillis, &outcome.transitions)
297
+ }
298
+
299
+ try self.store.save(state)
300
+ return outcome
301
+ }
302
+ }
303
+
304
+ /// A location fix from anywhere (significant changes, a fix asked for): re-checks polygons.
305
+ package func onFix(_ fix: Fix) async throws -> Outcome {
306
+ try await stateLock.withLock {
307
+ var state = try self.store.load()
308
+ guard let set = state.set else { return Outcome(refresh: true) }
309
+ var transitions: [Transition] = []
310
+
311
+ if Self.canCheckPolygon(fix, at: fix.timeMillis) {
312
+ self.checkPolygons(&state, Dictionary(set.geofences.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }), fix, fix.timeMillis, &transitions)
313
+ try self.store.save(state)
314
+ }
315
+ // Leaving the watch region is also caught here: significant-change fixes back up a
316
+ // small refresh region iOS may not fire for.
317
+ return Outcome(transitions: transitions, refresh: self.isDue(set, fix.position) || Self.leftWatchRegion(state, fix))
318
+ }
319
+ }
320
+
321
+ /// A fix when the OS isn't watching the geofences (the user allowed location only while the
322
+ /// app is in use): entering and leaving are worked out here, from the fix. A fix vaguer than
323
+ /// maxCircleAccuracyMeters decides nothing; leaving needs the device clearly outside (by more
324
+ /// than the fix's accuracy), so a jittery fix at the edge doesn't flap in and out.
325
+ package func onFixWithoutOs(_ fix: Fix) async throws -> Outcome {
326
+ let state = try await stateLock.withLock { try self.store.load() }
327
+ guard let set = state.set else { return Outcome(refresh: true) }
328
+ guard let accuracy = fix.accuracyMeters, accuracy <= Self.maxCircleAccuracyMeters else {
329
+ return Outcome(refresh: isDue(set, fix.position))
330
+ }
331
+
332
+ let entered = set.geofences.filter {
333
+ !state.insideCircles.contains($0.id) && Geo.distanceMeters($0.center, fix.position) <= Double($0.radiusMeters)
334
+ }.map(\.id)
335
+ let left = set.geofences.filter {
336
+ state.insideCircles.contains($0.id) && Geo.distanceMeters($0.center, fix.position) - accuracy > Double($0.radiusMeters)
337
+ }.map(\.id)
338
+
339
+ let leaving = try await transitions(left, entered: false, fix: fix, deviceTimeMillis: fix.timeMillis)
340
+ let entering = try await transitions(entered, entered: true, fix: fix, deviceTimeMillis: fix.timeMillis)
341
+ return Outcome(transitions: leaving.transitions + entering.transitions, refresh: isDue(set, fix.position), needsFix: entering.needsFix)
342
+ }
343
+
344
+ /// POST /geofence-events for `transition`. Safe to call again for the same one.
345
+ package func send(_ transition: Transition) async -> SendResult {
346
+ if clock.nowSeconds() * 1000 - transition.occurredAtMillis > Self.maxSendAgeMillis {
347
+ BubblLog.info("A geofence \(transition.enter ? "entry" : "exit") for \(transition.locationId) was given up on: too old to send")
348
+ return .dropped(reason: "stale")
349
+ }
350
+
351
+ struct Body: Encodable {
352
+ let location_id: String
353
+ let event: String
354
+ let occurred_at: String
355
+ let latitude: Double?
356
+ let longitude: Double?
357
+ let accuracy_meters: Double?
358
+ }
359
+ let body = Body(
360
+ location_id: transition.locationId,
361
+ event: transition.enter ? "enter" : "exit",
362
+ occurred_at: EventQueue.isoTimestamp(transition.occurredAtMillis),
363
+ latitude: transition.fix?.position.latitude,
364
+ longitude: transition.fix?.position.longitude,
365
+ accuracy_meters: transition.fix?.accuracyMeters
366
+ )
367
+
368
+ let response: ApiResponse
369
+ do {
370
+ let json = String(decoding: try JSONEncoder().encode(body), as: UTF8.self)
371
+ response = try await api.request("POST", "api/v1/geofence-events", body: json, headers: ["Idempotency-Key": transition.key])
372
+ } catch {
373
+ // Offline, or the Keychain not readable yet: try again later.
374
+ return .failed(.backoff)
375
+ }
376
+
377
+ if (200...299).contains(response.status) {
378
+ struct Delivered: Decodable {
379
+ let data: Notifications
380
+ struct Notifications: Decodable { let notifications: [JSONValue]? }
381
+ }
382
+ return .delivered(notifications: response.decode(Delivered.self)?.data.notifications ?? [])
383
+ }
384
+
385
+ let failure = ApiFailure.of(response)
386
+ guard case .drop(let refreshGeofences, let status, let code) = failure else { return .failed(failure) }
387
+ // The location has gone from the workspace: the next refresh fetches the set again.
388
+ if refreshGeofences { try? await markStale() }
389
+ BubblLog.warning("A geofence \(transition.enter ? "entry" : "exit") for \(transition.locationId) was refused: HTTP \(status) \(code ?? "")")
390
+ return .dropped(reason: code ?? "http_\(status)")
391
+ }
392
+
393
+ /// Stop watching and forget everything (opt-out, deleteMyData, stop).
394
+ package func stop() async throws {
395
+ await monitor.stop()
396
+ try await stateLock.withLock { try self.store.save(GeofenceState()) }
397
+ }
398
+
399
+ /// How many geofences the engine has (not counting its refresh region).
400
+ package func geofenceCount() async throws -> Int {
401
+ try await stateLock.withLock { try self.store.load().set?.geofences.count ?? 0 }
402
+ }
403
+
404
+ /// What should be watched now, e.g. to compare with what the OS has after a relaunch.
405
+ package func watchedRegions() async throws -> [WatchedRegion] {
406
+ let state = try await stateLock.withLock { try self.store.load() }
407
+ guard let set = state.set else { return [] }
408
+ return Self.regions(set, around: state.watchCenter ?? set.origin, capacity: await monitor.capacity(), excluding: await monitor.excluded()).regions
409
+ }
410
+
411
+ /// The geofences whose edges are nearest `center`, as many as fit in `capacity`, plus the
412
+ /// refresh region. When some are left out, the refresh region stops short of the nearest
413
+ /// edge among them, so the device picks again before it could reach one unwatched. Region
414
+ /// names in `excluding` (ones the OS can't watch) are passed over for the next nearest.
415
+ package static func regions(_ set: GeofenceSet, around center: LatLng, capacity: Int, excluding: Set<String> = []) -> (regions: [WatchedRegion], radius: Double?) {
416
+ guard capacity > 0 else { return ([], nil) }
417
+
418
+ // Distance to the edge, not the centre: a big geofence far off can be closer than a
419
+ // small one nearby.
420
+ let byEdge = set.geofences
421
+ .filter { !excluding.contains(regionId(for: $0.id)) }
422
+ .map { (geofence: $0, edge: max(0, Geo.distanceMeters(center, $0.center) - Double($0.radiusMeters))) }
423
+ .sorted { ($0.edge, $0.geofence.id) < ($1.edge, $1.geofence.id) }
424
+ let kept = byEdge.prefix(capacity - 1)
425
+ let leftOut = byEdge.dropFirst(kept.count)
426
+
427
+ var radius = Double(set.refreshDistanceMeters)
428
+ if let nearestLeftOut = leftOut.map(\.edge).min() {
429
+ radius = min(radius, max(minWatchRadiusMeters, nearestLeftOut))
430
+ }
431
+
432
+ let regions = kept.map { WatchedRegion(regionId(for: $0.geofence.id), $0.geofence.center, Double($0.geofence.radiusMeters)) }
433
+ return (regions + [WatchedRegion(refreshRegionId, center, radius)], radius)
434
+ }
435
+
436
+ private func checkPolygons(_ state: inout GeofenceState, _ byId: [String: Geofence], _ fix: Fix, _ deviceTimeMillis: Int64, _ transitions: inout [Transition]) {
437
+ for id in state.insideCircles.sorted() {
438
+ guard let geofence = byId[id], let ring = geofence.polygon else { continue }
439
+ let nowInside = Geo.contains(ring, fix.position)
440
+ if nowInside && !state.insidePolygons.contains(id) {
441
+ state.insidePolygons.insert(id)
442
+ if geofence.reportsEnter { transitions.append(transition(geofence, enter: true, deviceTimeMillis, fix)) }
443
+ } else if !nowInside && state.insidePolygons.contains(id) {
444
+ state.insidePolygons.remove(id)
445
+ if geofence.reportsExit { transitions.append(transition(geofence, enter: false, deviceTimeMillis, fix)) }
446
+ }
447
+ }
448
+ }
449
+
450
+ private func markStale() async throws {
451
+ try await stateLock.withLock {
452
+ var state = try self.store.load()
453
+ guard state.set != nil else { return }
454
+ state.set?.fetchedAtSeconds = 0
455
+ try self.store.save(state)
456
+ }
457
+ }
458
+
459
+ private func transition(_ geofence: Geofence, enter: Bool, _ deviceTimeMillis: Int64, _ fix: Fix?) -> Transition {
460
+ Transition(key: newKey(), locationId: geofence.id, enter: enter, occurredAtMillis: deviceTimeMillis + clock.offsetSeconds * 1000, fix: fix)
461
+ }
462
+
463
+ private func isDue(_ set: GeofenceSet, _ position: LatLng) -> Bool {
464
+ clock.nowSeconds() - set.fetchedAtSeconds >= set.refreshSeconds ||
465
+ Geo.distanceMeters(set.origin, position) >= Double(set.refreshDistanceMeters)
466
+ }
467
+
468
+ /// Clearly outside the region the nearest geofences were picked for (by more than the fix's
469
+ /// accuracy, as for leaving a geofence): time to pick again. A rough fix (a cell tower's,
470
+ /// kilometres wide) or one without an accuracy isn't clearly anywhere.
471
+ private static func leftWatchRegion(_ state: GeofenceState, _ fix: Fix) -> Bool {
472
+ guard let center = state.watchCenter, let radius = state.watchRadiusMeters, let accuracy = fix.accuracyMeters else { return false }
473
+ return Geo.distanceMeters(center, fix.position) - accuracy >= radius
474
+ }
475
+
476
+ /// Whether `fix` can say which side of a polygon's edge the device is: precise enough, and
477
+ /// taken no more than maxPolygonFixAgeMillis before `deviceTimeMillis` (nor after it).
478
+ private static func canCheckPolygon(_ fix: Fix, at deviceTimeMillis: Int64) -> Bool {
479
+ let age = deviceTimeMillis - fix.timeMillis
480
+ return (fix.accuracyMeters ?? .greatestFiniteMagnitude) <= maxPolygonAccuracyMeters &&
481
+ age >= 0 && age <= maxPolygonFixAgeMillis
482
+ }
483
+
484
+ /// Six decimal places is about 10 cm.
485
+ private static func coordinate(_ value: Double) -> String {
486
+ String(format: "%.6f", value)
487
+ }
488
+ }
@@ -0,0 +1,103 @@
1
+ import Foundation
2
+
3
+ /// What the engine knows between runs (the app may be killed between any two events).
4
+ package struct GeofenceState: Sendable, Hashable, Codable {
5
+ package var set: GeofenceSet?
6
+ /// Geofences whose circle the device is in, as the OS last said.
7
+ package var insideCircles: Set<String>
8
+ /// Polygon geofences the device is in (a subset of insideCircles).
9
+ package var insidePolygons: Set<String>
10
+ /// Whether the OS took the set on. False from saving a new set until the OS confirms, so a
11
+ /// watch cut short (the app killed, the OS refusing) is done again at the next check.
12
+ package var watching: Bool
13
+ /// The centre and radius of the refresh region as last watched: where the device was when
14
+ /// its nearest geofences were picked (iOS can't watch them all at once).
15
+ package var watchCenter: LatLng?
16
+ package var watchRadiusMeters: Double?
17
+
18
+ package init(
19
+ set: GeofenceSet? = nil,
20
+ insideCircles: Set<String> = [],
21
+ insidePolygons: Set<String> = [],
22
+ watching: Bool = false,
23
+ watchCenter: LatLng? = nil,
24
+ watchRadiusMeters: Double? = nil
25
+ ) {
26
+ self.set = set
27
+ self.insideCircles = insideCircles
28
+ self.insidePolygons = insidePolygons
29
+ self.watching = watching
30
+ self.watchCenter = watchCenter
31
+ self.watchRadiusMeters = watchRadiusMeters
32
+ }
33
+ }
34
+
35
+ /// The state file couldn't be read or written, so it did neither. Before the first unlock after a
36
+ /// reboot iOS keeps protected files closed: an unreadable state is never taken for "no state",
37
+ /// which would forget where the device is and write over the file.
38
+ package struct GeofenceStateUnavailable: Error {
39
+ package let underlying: any Error
40
+ }
41
+
42
+ package protocol GeofenceStateStore: Sendable {
43
+ func load() throws -> GeofenceState
44
+ func save(_ state: GeofenceState) throws
45
+ }
46
+
47
+ /// GeofenceState in memory: for tests.
48
+ package final class InMemoryGeofenceStateStore: GeofenceStateStore, @unchecked Sendable {
49
+ private let lock = NSLock()
50
+ private var state: GeofenceState
51
+
52
+ package init(_ state: GeofenceState = GeofenceState()) {
53
+ self.state = state
54
+ }
55
+
56
+ package func load() -> GeofenceState { lock.sync { state } }
57
+
58
+ package func save(_ state: GeofenceState) {
59
+ lock.sync { self.state = state }
60
+ }
61
+ }
62
+
63
+ /// GeofenceState as a small JSON file, written atomically (on iOS, with file protection and kept
64
+ /// out of backups: a restored backup mustn't claim the device is inside places it isn't).
65
+ ///
66
+ /// A missing file is no state; a file that reads but doesn't decode is no state too (the next
67
+ /// refresh starts afresh). A file that can't be read at all throws GeofenceStateUnavailable.
68
+ package final class FileGeofenceStateStore: GeofenceStateStore, @unchecked Sendable {
69
+ private let lock = NSLock()
70
+ private let url: URL
71
+ private let writeOptions: Data.WritingOptions
72
+
73
+ /// - Parameter writeOptions: added to `.atomic`; on iOS, the file protection class.
74
+ package init(url: URL, writeOptions: Data.WritingOptions = []) {
75
+ self.url = url
76
+ self.writeOptions = writeOptions.union(.atomic)
77
+ }
78
+
79
+ package func load() throws -> GeofenceState {
80
+ try lock.sync {
81
+ guard FileManager.default.fileExists(atPath: url.path) else { return GeofenceState() }
82
+ let data: Data
83
+ do {
84
+ data = try Data(contentsOf: url)
85
+ } catch {
86
+ throw GeofenceStateUnavailable(underlying: error)
87
+ }
88
+ return (try? JSONDecoder().decode(GeofenceState.self, from: data)) ?? GeofenceState()
89
+ }
90
+ }
91
+
92
+ package func save(_ state: GeofenceState) throws {
93
+ try lock.sync {
94
+ let data = try JSONEncoder().encode(state)
95
+ do {
96
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
97
+ try data.write(to: url, options: writeOptions)
98
+ } catch {
99
+ throw GeofenceStateUnavailable(underlying: error)
100
+ }
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,62 @@
1
+ import Foundation
2
+
3
+ /// How many regions Bubbl may ask the OS to watch, learnt from what the OS refuses (iOS's CLMonitor
4
+ /// says so per condition, after the fact). iOS's 20 are shared with the app's own and other SDKs'
5
+ /// conditions, which Bubbl can't see: when some of Bubbl's go unmonitored for being over the
6
+ /// limit, Bubbl asks for that many fewer from then on (never fewer than one, the refresh region),
7
+ /// rather than asking for the same number again and again.
8
+ ///
9
+ /// A condition the OS can't monitor at all ("unsupported") is dropped on its own; only when all of
10
+ /// Bubbl's are, or the refresh region is, does the device count as unable to watch geofences (and
11
+ /// the platform decides entering and leaving from fixes instead).
12
+ package struct RegionBudget: Sendable, Equatable {
13
+ package enum Unsupported: Sendable, Equatable {
14
+ /// Only this condition: the rest are watched.
15
+ case dropOne
16
+ /// Nothing can be watched here: decide from fixes.
17
+ case deviceCantWatch
18
+ }
19
+
20
+ package static let osLimit = 20
21
+
22
+ /// Bubbl's own cap, lowered by each condition refused for being over the limit.
23
+ package private(set) var cap = RegionBudget.osLimit
24
+ private var watched: Set<String> = []
25
+ private var overLimit: Set<String> = []
26
+ /// For the life of the process: the OS won't take these however often they're asked for.
27
+ private var unsupported: Set<String> = []
28
+
29
+ /// Bubbl's conditions the OS can't watch here, for the engine to pick others in their place.
30
+ package var excluded: Set<String> { unsupported }
31
+
32
+ package init() {}
33
+
34
+ /// What Bubbl may watch: the OS's limit less the regions it can see others watching, and no
35
+ /// more than its own cap.
36
+ package func capacity(othersVisible: Int) -> Int {
37
+ max(0, min(Self.osLimit - othersVisible, cap))
38
+ }
39
+
40
+ /// Bubbl has just asked the OS to watch `ids` (replacing what it watched before).
41
+ package mutating func watching(_ ids: [String]) {
42
+ watched = Set(ids)
43
+ overLimit = []
44
+ }
45
+
46
+ /// The OS isn't watching `id` because the app is over its limit. True when that lowered the
47
+ /// cap (so the next watch asks for fewer).
48
+ package mutating func overLimit(_ id: String) -> Bool {
49
+ guard watched.contains(id), overLimit.insert(id).inserted else { return false }
50
+ let lowered = max(1, watched.count - overLimit.count)
51
+ guard lowered < cap else { return false }
52
+ cap = lowered
53
+ return true
54
+ }
55
+
56
+ /// The OS can't watch `id` at all. Nil when that was known already (nothing new to say).
57
+ package mutating func unsupported(_ id: String, refreshRegionId: String) -> Unsupported? {
58
+ guard unsupported.insert(id).inserted else { return nil }
59
+ if id == refreshRegionId || (!watched.isEmpty && watched.isSubset(of: unsupported)) { return .deviceCantWatch }
60
+ return .dropOne
61
+ }
62
+ }