@bubblsdk/react-native-sdk 4.1.7 → 5.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BubblReactNativeSdk.podspec +29 -9
- package/CHANGELOG.md +52 -1
- package/LICENSE +202 -1
- package/README.md +172 -83
- package/android/build.gradle +33 -30
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
- package/app.plugin.js +2 -0
- package/ios/BubblPush.swift +44 -0
- package/ios/BubblSdk.h +7 -0
- package/ios/BubblSdk.mm +252 -0
- package/ios/BubblSdkImpl.swift +335 -0
- package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
- package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
- package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
- package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
- package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
- package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
- package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
- package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
- package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
- package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
- package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-sdk/BubblCore/API/Http.swift +39 -0
- package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
- package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
- package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
- package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
- package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
- package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
- package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
- package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
- package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
- package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
- package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
- package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
- package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
- package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
- package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
- package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
- package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
- package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
- package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
- package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
- package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
- package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
- package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
- package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
- package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
- package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
- package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
- package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
- package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
- package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
- package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
- package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
- package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
- package/jest/index.js +75 -0
- package/lib/commonjs/BubblModule.js +14 -0
- package/lib/commonjs/BubblModule.js.map +1 -0
- package/lib/commonjs/BubblModule.web.js +13 -0
- package/lib/commonjs/BubblModule.web.js.map +1 -0
- package/lib/commonjs/NativeBubblSdk.js +20 -0
- package/lib/commonjs/NativeBubblSdk.js.map +1 -0
- package/lib/commonjs/index.js +336 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/BubblModule.js +5 -0
- package/lib/module/BubblModule.js.map +1 -0
- package/lib/module/BubblModule.web.js +9 -0
- package/lib/module/BubblModule.web.js.map +1 -0
- package/lib/module/NativeBubblSdk.js +18 -0
- package/lib/module/NativeBubblSdk.js.map +1 -0
- package/lib/module/index.js +331 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +111 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/types.d.ts +192 -0
- package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/BubblModule.d.ts +3 -0
- package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +111 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/types.d.ts +192 -0
- package/lib/typescript/module/src/types.d.ts.map +1 -0
- package/package.json +105 -24
- package/plugin/build/imageNotifications.d.ts +35 -0
- package/plugin/build/imageNotifications.js +198 -0
- package/plugin/build/index.d.ts +84 -0
- package/plugin/build/index.js +176 -0
- package/src/BubblModule.ts +2 -0
- package/src/BubblModule.web.ts +9 -0
- package/src/NativeBubblSdk.ts +67 -0
- package/src/index.ts +338 -282
- package/src/types.ts +182 -0
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
- package/ios/BubblSdk.swift +0 -802
- package/ios/BubblSdkBridge.m +0 -86
- package/react-native.config.js +0 -11
- package/src/specs/NativeBubblSdk.ts +0 -54
|
@@ -0,0 +1,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
|
+
}
|