@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,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
|