@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,149 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The workspace an API key belongs to, from POST /installs and GET /config (`workspace`):
|
|
4
|
+
/// Sandbox (a pk_test_ key: free, and only approved test devices get anything) or Production
|
|
5
|
+
/// (pk_live_). In Sandbox, `testDevice` says where this device stands: pending until someone
|
|
6
|
+
/// approves it (with the short `code` the dashboard lists it under), then approved.
|
|
7
|
+
package struct Workspace: Sendable, Equatable {
|
|
8
|
+
package struct TestDevice: Sendable, Equatable {
|
|
9
|
+
/// pending or approved (as the server says; a newer server may add others). An approved phone
|
|
10
|
+
/// unused for 30 days goes back to pending; a revoked one registers again as pending.
|
|
11
|
+
package let status: String
|
|
12
|
+
/// While pending: the short code the dashboard lists this device under (e.g. K7Q-2MX).
|
|
13
|
+
package let code: String?
|
|
14
|
+
|
|
15
|
+
package init(status: String, code: String?) {
|
|
16
|
+
self.status = status
|
|
17
|
+
self.code = code
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// "sandbox" or "production".
|
|
22
|
+
package let environment: String
|
|
23
|
+
package let testDevice: TestDevice?
|
|
24
|
+
|
|
25
|
+
package init(environment: String, testDevice: TestDevice?) {
|
|
26
|
+
self.environment = environment
|
|
27
|
+
self.testDevice = testDevice
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
package var isSandbox: Bool { environment == "sandbox" }
|
|
31
|
+
|
|
32
|
+
/// What the log says about it, at start and when it changes: in Sandbox, how to get this device
|
|
33
|
+
/// approved; nothing in Production (or from a server that predates Sandbox).
|
|
34
|
+
package var announcement: (warning: Bool, message: String)? {
|
|
35
|
+
guard isSandbox, let testDevice else { return nil }
|
|
36
|
+
switch testDevice.status {
|
|
37
|
+
case "pending":
|
|
38
|
+
guard let code = testDevice.code else { return (true, "Bubbl sandbox: this device is waiting to be approved as a test device (Test devices in the dashboard)") }
|
|
39
|
+
return (true, "Bubbl sandbox: approve this device with code \(code)")
|
|
40
|
+
case "approved":
|
|
41
|
+
return (false, "Bubbl sandbox: this device is an approved test device")
|
|
42
|
+
default:
|
|
43
|
+
return nil
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
extension SdkConfig {
|
|
49
|
+
/// Nil from a server that predates Sandbox (every workspace was Production then).
|
|
50
|
+
package var workspace: Workspace? {
|
|
51
|
+
guard let block = json["workspace"], let environment = block["environment"]?.nonEmptyString else { return nil }
|
|
52
|
+
let device = block["test_device"]
|
|
53
|
+
let testDevice = device?["status"]?.nonEmptyString.map { Workspace.TestDevice(status: $0, code: device?["code"]?.nonEmptyString) }
|
|
54
|
+
return Workspace(environment: environment, testDevice: testDevice)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
extension EngineStart {
|
|
59
|
+
/// The console's warning for a key that doesn't suit the build: a Sandbox key in a release
|
|
60
|
+
/// build (only approved test devices would get anything), or a Production key in a debug build
|
|
61
|
+
/// (development should go to Sandbox). Nil when they suit, or for a key without a prefix.
|
|
62
|
+
package static func keyWarning(apiKey: String, debugBuild: Bool) -> String? {
|
|
63
|
+
if apiKey.hasPrefix("pk_test_") && !debugBuild {
|
|
64
|
+
return "Bubbl: a Sandbox key (pk_test_) in a release build. Only approved test devices get anything from Sandbox: to go live, use the Production key (pk_live_) and https://api.bubbl.tech"
|
|
65
|
+
}
|
|
66
|
+
if apiKey.hasPrefix("pk_live_") && debugBuild {
|
|
67
|
+
return "Bubbl: a Production key (pk_live_) in a debug build. Develop against Sandbox: its key (pk_test_) and https://api.sandbox.bubbl.tech"
|
|
68
|
+
}
|
|
69
|
+
return nil
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
extension EngineCore {
|
|
74
|
+
/// The workspace this key belongs to, as last heard; nil before the first registration.
|
|
75
|
+
package var workspace: Workspace? { configSync.current?.workspace }
|
|
76
|
+
|
|
77
|
+
/// Says in the log where this device stands in Sandbox (its approval code while pending), once
|
|
78
|
+
/// per status and code in a process: at start, and when a registration or the config brings a
|
|
79
|
+
/// change.
|
|
80
|
+
package func announceSandbox() {
|
|
81
|
+
guard let workspace, let said = workspace.announcement else { return }
|
|
82
|
+
guard sandboxAnnounced.changed(to: said.message) else { return }
|
|
83
|
+
if said.warning { BubblLog.warning(said.message) } else { BubblLog.info(said.message) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// POST /test-device: approves this device in Sandbox with a registration code from the
|
|
87
|
+
/// dashboard (single-use, 24 hours). Approved, with the workspace block the server returns kept
|
|
88
|
+
/// at once; or not, with why: the server's message (a wrong, used or expired code, the Sandbox
|
|
89
|
+
/// full), or the SDK's (not running, an empty code, no network). Said in the log too; the code
|
|
90
|
+
/// itself never is.
|
|
91
|
+
package func registerTestDevice(_ code: String) async -> TestDeviceResult {
|
|
92
|
+
func refused(_ why: String) -> TestDeviceResult {
|
|
93
|
+
BubblLog.warning("Bubbl.registerTestDevice: \(why)")
|
|
94
|
+
return TestDeviceResult(approved: false, message: why)
|
|
95
|
+
}
|
|
96
|
+
guard isActive else { return refused("Bubbl isn't running (consent, a pause, or the SDK's minimum version)") }
|
|
97
|
+
let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
98
|
+
guard !trimmed.isEmpty else { return refused("the code is empty") }
|
|
99
|
+
|
|
100
|
+
let response: ApiResponse
|
|
101
|
+
do {
|
|
102
|
+
response = try await api.request("POST", "api/v1/test-device", body: JSON.string(["code": trimmed]))
|
|
103
|
+
} catch {
|
|
104
|
+
return refused("the device API couldn't be reached; try again")
|
|
105
|
+
}
|
|
106
|
+
guard response.isSuccessful else {
|
|
107
|
+
let message = (response.json?["message"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
|
108
|
+
return refused(message ?? "not approved (\(response.code ?? "HTTP \(response.status)"))")
|
|
109
|
+
}
|
|
110
|
+
struct Body: Decodable {
|
|
111
|
+
struct Content: Decodable { let workspace: JSONValue }
|
|
112
|
+
let data: Content
|
|
113
|
+
}
|
|
114
|
+
if let workspace = response.decode(Body.self)?.data.workspace {
|
|
115
|
+
try? await configSync.setWorkspace(workspace)
|
|
116
|
+
} else {
|
|
117
|
+
_ = await configSync.refresh(force: true)
|
|
118
|
+
}
|
|
119
|
+
announceSandbox()
|
|
120
|
+
return TestDeviceResult(approved: true, message: nil)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/// What `registerTestDevice` came to.
|
|
125
|
+
package struct TestDeviceResult: Sendable, Equatable {
|
|
126
|
+
package let approved: Bool
|
|
127
|
+
/// Why not, when not approved.
|
|
128
|
+
package let message: String?
|
|
129
|
+
|
|
130
|
+
package init(approved: Bool, message: String?) {
|
|
131
|
+
self.approved = approved
|
|
132
|
+
self.message = message
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// The last thing said, so a message is logged once until it changes.
|
|
137
|
+
final class LastSaid: @unchecked Sendable {
|
|
138
|
+
private let lock = NSLock()
|
|
139
|
+
private var last: String?
|
|
140
|
+
|
|
141
|
+
/// True, and remembered, when `message` differs from the last one.
|
|
142
|
+
func changed(to message: String) -> Bool {
|
|
143
|
+
lock.sync {
|
|
144
|
+
guard message != last else { return false }
|
|
145
|
+
last = message
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// How a flush ended, so whoever scheduled it (a background task, an app resume) knows what next.
|
|
4
|
+
package enum FlushResult: Sendable, Equatable {
|
|
5
|
+
/// Everything queued was sent (accepted, a duplicate, or rejected for good).
|
|
6
|
+
case done(sent: Int, rejected: [Rejection])
|
|
7
|
+
/// Stopped part way; what's left stays queued.
|
|
8
|
+
case failed(ApiFailure)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/// An event that can never be sent as it is (its data has a NaN or infinite number, which JSON
|
|
12
|
+
/// can't carry): refused by enqueue rather than queued.
|
|
13
|
+
package struct InvalidEvent: Error, Equatable {
|
|
14
|
+
package let type: String
|
|
15
|
+
|
|
16
|
+
package init(type: String) {
|
|
17
|
+
self.type = type
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// An event the server refused for good, with its reasons: logged, never retried.
|
|
22
|
+
package struct Rejection: Sendable, Equatable {
|
|
23
|
+
package let id: String
|
|
24
|
+
package let type: String
|
|
25
|
+
/// The server's reasons, as JSON.
|
|
26
|
+
package let errors: String
|
|
27
|
+
/// The names of the fields it refused, for the log.
|
|
28
|
+
package let fields: [String]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// The offline event queue behind POST /events. Events are stored the moment they happen and sent
|
|
32
|
+
/// in batches of `batchSize` (at most 100), oldest first. Each carries its own id, which the server
|
|
33
|
+
/// de-duplicates on, so a batch whose answer was lost can simply be sent again.
|
|
34
|
+
///
|
|
35
|
+
/// Capped at `maxEvents` and `maxAgeMillis`: past either, the oldest go (analytics from days ago
|
|
36
|
+
/// isn't worth unbounded storage). One flush at a time. A request that couldn't be made (offline,
|
|
37
|
+
/// the Keychain or the store not readable yet) never drops anything: it's "try again later".
|
|
38
|
+
package final class EventQueue: Sendable {
|
|
39
|
+
package static let batchSize = 100
|
|
40
|
+
package static let maxEvents = 1_000
|
|
41
|
+
package static let maxAgeMillis: Int64 = 72 * 60 * 60 * 1000
|
|
42
|
+
|
|
43
|
+
private let store: any EventStore
|
|
44
|
+
private let api: DeviceApiClient
|
|
45
|
+
private let clock: ServerClock
|
|
46
|
+
private let batchSize: @Sendable () -> Int
|
|
47
|
+
private let newId: @Sendable () -> String
|
|
48
|
+
private let flushing = AsyncMutex()
|
|
49
|
+
|
|
50
|
+
/// - Parameter batchSize: GET /config's max_events_per_request, once known; never more than 100.
|
|
51
|
+
package init(
|
|
52
|
+
store: any EventStore,
|
|
53
|
+
api: DeviceApiClient,
|
|
54
|
+
clock: ServerClock,
|
|
55
|
+
batchSize: @escaping @Sendable () -> Int = { EventQueue.batchSize },
|
|
56
|
+
newId: @escaping @Sendable () -> String = { UUID().uuidString.lowercased() }
|
|
57
|
+
) {
|
|
58
|
+
self.store = store
|
|
59
|
+
self.api = api
|
|
60
|
+
self.clock = clock
|
|
61
|
+
self.batchSize = batchSize
|
|
62
|
+
self.newId = newId
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Queue an event of `type` ("notification.displayed", …) with its `data`, as happening now on
|
|
66
|
+
/// the server's clock. Returns the event's id. Throws InvalidEvent when it can never be sent
|
|
67
|
+
/// (a NaN or infinite number), and EventStoreUnavailable when it can't be stored yet.
|
|
68
|
+
@discardableResult
|
|
69
|
+
package func enqueue(_ type: String, data: [String: JSONValue] = [:], occurredAtMillis: Int64? = nil) async throws -> String {
|
|
70
|
+
// Refused here, before the store: stored, it would stop every flush of its batch.
|
|
71
|
+
guard JSONValue.object(data).isEncodable else {
|
|
72
|
+
BubblLog.warning("Event \(type) refused: its data has a number JSON can't carry (NaN or infinite)")
|
|
73
|
+
throw InvalidEvent(type: type)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let id = newId()
|
|
77
|
+
try await store.add(QueuedEvent(id: id, type: type, occurredAtMillis: occurredAtMillis ?? clock.nowSeconds() * 1000, data: data))
|
|
78
|
+
|
|
79
|
+
let overflow = try await store.count() - Self.maxEvents
|
|
80
|
+
if overflow > 0 { try await store.dropOldest(overflow) }
|
|
81
|
+
return id
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Send everything queued, batch by batch, until the queue is empty or the server says stop.
|
|
85
|
+
package func flush() async -> FlushResult {
|
|
86
|
+
await flushing.withLock { await self.flushLocked() }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// How many events are waiting to be sent.
|
|
90
|
+
package func size() async throws -> Int { try await store.count() }
|
|
91
|
+
|
|
92
|
+
/// Drop everything queued (opt-out, deleteMyData).
|
|
93
|
+
package func clear() async throws { try await store.clear() }
|
|
94
|
+
|
|
95
|
+
private func flushLocked() async -> FlushResult {
|
|
96
|
+
var sent = 0
|
|
97
|
+
var rejected: [Rejection] = []
|
|
98
|
+
let size = min(max(batchSize(), 1), Self.batchSize)
|
|
99
|
+
|
|
100
|
+
do {
|
|
101
|
+
try await store.dropOccurred(before: clock.nowSeconds() * 1000 - Self.maxAgeMillis)
|
|
102
|
+
|
|
103
|
+
while true {
|
|
104
|
+
let batch = try await store.oldest(size)
|
|
105
|
+
if batch.isEmpty { return .done(sent: sent, rejected: rejected) }
|
|
106
|
+
|
|
107
|
+
let body: String
|
|
108
|
+
do {
|
|
109
|
+
body = try Self.body(batch)
|
|
110
|
+
} catch {
|
|
111
|
+
// Can't be written as JSON (enqueue refuses such events, so only a store
|
|
112
|
+
// filled some other way): drop the batch rather than retry it forever.
|
|
113
|
+
BubblLog.warning("Dropped \(batch.count) queued event(s) that can't be written as JSON")
|
|
114
|
+
try await store.remove(Set(batch.map(\.id)))
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let response = try await api.request("POST", "api/v1/events", body: body)
|
|
119
|
+
|
|
120
|
+
// 202 is the contract's answer; any 2xx (a proxy's 200) settles the batch too.
|
|
121
|
+
if (200...299).contains(response.status) {
|
|
122
|
+
let refused = Self.rejections(batch, response)
|
|
123
|
+
// Field names only: the errors can quote what was sent.
|
|
124
|
+
refused.forEach { BubblLog.warning("Event \($0.type) \($0.id) rejected by the server (fields: \($0.fields.joined(separator: ", ")))") }
|
|
125
|
+
rejected += refused
|
|
126
|
+
// Accepted, duplicate or rejected: each has had its answer, so none is resent.
|
|
127
|
+
try await store.remove(Set(batch.map(\.id)))
|
|
128
|
+
sent += batch.count
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let failure = ApiFailure.of(response)
|
|
133
|
+
// Only a malformed batch gets a 422, and resending it can't help: drop it rather
|
|
134
|
+
// than wedge the queue behind it.
|
|
135
|
+
guard case .drop(_, let status, let code) = failure else { return .failed(failure) }
|
|
136
|
+
BubblLog.warning("Dropped a batch of \(batch.count) event(s) the server refused (HTTP \(status) \(code ?? ""))")
|
|
137
|
+
try await store.remove(Set(batch.map(\.id)))
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// Offline, the credential or the store not readable yet: nothing refused, all kept.
|
|
141
|
+
return .failed(.backoff)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// MARK: - The wire format (contracts/v1: POST /events)
|
|
146
|
+
|
|
147
|
+
private struct Body: Encodable {
|
|
148
|
+
let events: [Event]
|
|
149
|
+
|
|
150
|
+
struct Event: Encodable {
|
|
151
|
+
let id: String
|
|
152
|
+
let type: String
|
|
153
|
+
let occurred_at: String
|
|
154
|
+
let data: [String: JSONValue]
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private struct Response: Decodable {
|
|
159
|
+
let data: Results
|
|
160
|
+
|
|
161
|
+
struct Results: Decodable {
|
|
162
|
+
let results: [Result]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
struct Result: Decodable {
|
|
166
|
+
let id: String
|
|
167
|
+
let status: String
|
|
168
|
+
let errors: JSONValue?
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private static func body(_ batch: [QueuedEvent]) throws -> String {
|
|
173
|
+
let events = batch.map { Body.Event(id: $0.id, type: $0.type, occurred_at: isoTimestamp($0.occurredAtMillis), data: $0.data) }
|
|
174
|
+
return String(decoding: try JSONEncoder().encode(Body(events: events)), as: UTF8.self)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// The events a 202 marked rejected, with the server's reasons.
|
|
178
|
+
private static func rejections(_ batch: [QueuedEvent], _ response: ApiResponse) -> [Rejection] {
|
|
179
|
+
guard let results = response.decode(Response.self)?.data.results else { return [] }
|
|
180
|
+
let types = Dictionary(batch.map { ($0.id, $0.type) }, uniquingKeysWith: { first, _ in first })
|
|
181
|
+
|
|
182
|
+
return results.filter { $0.status == "rejected" }.map { result in
|
|
183
|
+
let errors = result.errors.flatMap { try? JSONEncoder().encode($0) }.map { String(decoding: $0, as: UTF8.self) } ?? ""
|
|
184
|
+
let fields = if case .object(let byField)? = result.errors { byField.keys.sorted() } else { [String]() }
|
|
185
|
+
return Rejection(id: result.id, type: types[result.id] ?? "", errors: errors, fields: fields)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/// Unix milliseconds as ISO 8601 in UTC ("2026-09-24T10:15:30Z", with milliseconds when there
|
|
190
|
+
/// are any), as the contract's occurred_at.
|
|
191
|
+
package static func isoTimestamp(_ millis: Int64) -> String {
|
|
192
|
+
var calendar = Calendar(identifier: .gregorian)
|
|
193
|
+
calendar.timeZone = TimeZone(identifier: "UTC") ?? TimeZone(secondsFromGMT: 0) ?? calendar.timeZone
|
|
194
|
+
let date = Date(timeIntervalSince1970: TimeInterval(millis / 1000))
|
|
195
|
+
let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
|
|
196
|
+
let day = "\(pad(parts.year ?? 0, 4))-\(pad(parts.month ?? 0, 2))-\(pad(parts.day ?? 0, 2))"
|
|
197
|
+
let time = "\(pad(parts.hour ?? 0, 2)):\(pad(parts.minute ?? 0, 2)):\(pad(parts.second ?? 0, 2))"
|
|
198
|
+
let fraction = Int(millis % 1000)
|
|
199
|
+
return "\(day)T\(time)" + (fraction == 0 ? "Z" : ".\(pad(fraction, 3))Z")
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// `value` with leading zeros to `width` digits.
|
|
203
|
+
private static func pad(_ value: Int, _ width: Int) -> String {
|
|
204
|
+
let digits = String(value)
|
|
205
|
+
return String(repeating: "0", count: max(width - digits.count, 0)) + digits
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// One event waiting to be sent to POST /events: the id the server de-duplicates on, its type,
|
|
4
|
+
/// when it happened (unix milliseconds, on the server's clock) and its `data` object.
|
|
5
|
+
package struct QueuedEvent: Sendable, Equatable, Codable {
|
|
6
|
+
package let id: String
|
|
7
|
+
package let type: String
|
|
8
|
+
package let occurredAtMillis: Int64
|
|
9
|
+
package let data: [String: JSONValue]
|
|
10
|
+
|
|
11
|
+
package init(id: String, type: String, occurredAtMillis: Int64, data: [String: JSONValue] = [:]) {
|
|
12
|
+
self.id = id
|
|
13
|
+
self.type = type
|
|
14
|
+
self.occurredAtMillis = occurredAtMillis
|
|
15
|
+
self.data = data
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/// The store couldn't be read or written, so it did neither: nothing was lost or overwritten.
|
|
20
|
+
/// Before the first unlock after a reboot iOS keeps protected files closed; try again later.
|
|
21
|
+
package struct EventStoreUnavailable: Error {
|
|
22
|
+
package let underlying: any Error
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Where queued events wait: on disk on a device, so they survive the app being killed and the
|
|
26
|
+
/// phone being offline. Oldest first throughout (id breaks ties).
|
|
27
|
+
package protocol EventStore: Actor {
|
|
28
|
+
func add(_ event: QueuedEvent) throws
|
|
29
|
+
/// Up to `limit` of the oldest events, oldest first.
|
|
30
|
+
func oldest(_ limit: Int) throws -> [QueuedEvent]
|
|
31
|
+
func remove(_ ids: Set<String>) throws
|
|
32
|
+
func count() throws -> Int
|
|
33
|
+
/// Drops the `count` oldest events (the queue's size cap).
|
|
34
|
+
func dropOldest(_ count: Int) throws
|
|
35
|
+
/// Drops events that happened before `millis` (the queue's age cap); returns how many.
|
|
36
|
+
@discardableResult func dropOccurred(before millis: Int64) throws -> Int
|
|
37
|
+
/// Drops everything (opt-out, deleteMyData).
|
|
38
|
+
func clear() throws
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// The events themselves, however they're kept: shared by the memory and file stores.
|
|
42
|
+
private struct Events {
|
|
43
|
+
var all: [QueuedEvent] = []
|
|
44
|
+
|
|
45
|
+
mutating func add(_ event: QueuedEvent) {
|
|
46
|
+
if !all.contains(where: { $0.id == event.id }) { all.append(event) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func oldest(_ limit: Int) -> [QueuedEvent] { Array(sorted().prefix(max(limit, 0))) }
|
|
50
|
+
|
|
51
|
+
mutating func remove(_ ids: Set<String>) { all.removeAll { ids.contains($0.id) } }
|
|
52
|
+
|
|
53
|
+
mutating func dropOldest(_ count: Int) { remove(Set(oldest(count).map(\.id))) }
|
|
54
|
+
|
|
55
|
+
mutating func dropOccurred(before millis: Int64) -> Int {
|
|
56
|
+
let before = all.count
|
|
57
|
+
all.removeAll { $0.occurredAtMillis < millis }
|
|
58
|
+
return before - all.count
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// By when they happened; events of the same millisecond in the order they were recorded (not
|
|
62
|
+
/// by id, which is random).
|
|
63
|
+
private func sorted() -> [QueuedEvent] {
|
|
64
|
+
all.enumerated()
|
|
65
|
+
.sorted { ($0.element.occurredAtMillis, $0.offset) < ($1.element.occurredAtMillis, $1.offset) }
|
|
66
|
+
.map(\.element)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// An EventStore in memory: for tests.
|
|
71
|
+
package actor InMemoryEventStore: EventStore {
|
|
72
|
+
private var events = Events()
|
|
73
|
+
|
|
74
|
+
package init() {}
|
|
75
|
+
|
|
76
|
+
package func add(_ event: QueuedEvent) { events.add(event) }
|
|
77
|
+
package func oldest(_ limit: Int) -> [QueuedEvent] { events.oldest(limit) }
|
|
78
|
+
package func remove(_ ids: Set<String>) { events.remove(ids) }
|
|
79
|
+
package func count() -> Int { events.all.count }
|
|
80
|
+
package func dropOldest(_ count: Int) { events.dropOldest(count) }
|
|
81
|
+
@discardableResult package func dropOccurred(before millis: Int64) -> Int { events.dropOccurred(before: millis) }
|
|
82
|
+
package func clear() { events.all.removeAll() }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// An EventStore in one JSON file, written whole and atomically on every change (the queue is
|
|
86
|
+
/// capped at 1000 small events). Loaded on first use.
|
|
87
|
+
///
|
|
88
|
+
/// A file that exists but can't be read (protected before the first unlock) is never taken for
|
|
89
|
+
/// an empty queue: every call throws EventStoreUnavailable and nothing is written over it until
|
|
90
|
+
/// it can be read. A missing file is an empty queue.
|
|
91
|
+
///
|
|
92
|
+
/// One process only: the file is cached in memory and rewritten whole from that cache, so a
|
|
93
|
+
/// second writer's events would be lost. The notification service extension (a separate process)
|
|
94
|
+
/// must never share this file; anything it reports goes its own way (its own file, merged by the
|
|
95
|
+
/// app, or a request of its own).
|
|
96
|
+
package actor FileEventStore: EventStore {
|
|
97
|
+
private let url: URL
|
|
98
|
+
private let writeOptions: Data.WritingOptions
|
|
99
|
+
private var loaded: Events?
|
|
100
|
+
|
|
101
|
+
/// - Parameter writeOptions: added to `.atomic`; on iOS, the file protection class.
|
|
102
|
+
package init(url: URL, writeOptions: Data.WritingOptions = []) {
|
|
103
|
+
self.url = url
|
|
104
|
+
self.writeOptions = writeOptions.union(.atomic)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
package func add(_ event: QueuedEvent) throws { try change { $0.add(event) } }
|
|
108
|
+
package func oldest(_ limit: Int) throws -> [QueuedEvent] { try events().oldest(limit) }
|
|
109
|
+
package func remove(_ ids: Set<String>) throws { try change { $0.remove(ids) } }
|
|
110
|
+
package func count() throws -> Int { try events().all.count }
|
|
111
|
+
package func dropOldest(_ count: Int) throws { try change { $0.dropOldest(count) } }
|
|
112
|
+
|
|
113
|
+
@discardableResult
|
|
114
|
+
package func dropOccurred(before millis: Int64) throws -> Int {
|
|
115
|
+
var dropped = 0
|
|
116
|
+
try change { dropped = $0.dropOccurred(before: millis) }
|
|
117
|
+
return dropped
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
package func clear() throws { try change { $0.all.removeAll() } }
|
|
121
|
+
|
|
122
|
+
private func events() throws -> Events {
|
|
123
|
+
if let loaded { return loaded }
|
|
124
|
+
|
|
125
|
+
var events = Events()
|
|
126
|
+
if FileManager.default.fileExists(atPath: url.path) {
|
|
127
|
+
let data: Data
|
|
128
|
+
do {
|
|
129
|
+
data = try Data(contentsOf: url)
|
|
130
|
+
} catch {
|
|
131
|
+
// Can't be read yet (protected until the first unlock): wait, don't overwrite.
|
|
132
|
+
throw EventStoreUnavailable(underlying: error)
|
|
133
|
+
}
|
|
134
|
+
// Read but not understood (corrupt, or from an incompatible build): starting afresh
|
|
135
|
+
// loses some analytics; keeping it would stop the queue for good.
|
|
136
|
+
if let decoded = try? JSONDecoder().decode([QueuedEvent].self, from: data) {
|
|
137
|
+
events = Events(all: decoded)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
loaded = events
|
|
141
|
+
return events
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// Applies `body` and writes the result; memory changes only once the file has. Only reading
|
|
145
|
+
/// or writing the file counts as "unavailable": an event that can't be encoded throws its
|
|
146
|
+
/// EncodingError, since trying again later can't help it.
|
|
147
|
+
private func change(_ body: (inout Events) -> Void) throws {
|
|
148
|
+
var events = try events()
|
|
149
|
+
body(&events)
|
|
150
|
+
let data = try JSONEncoder().encode(events.all)
|
|
151
|
+
do {
|
|
152
|
+
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
153
|
+
try data.write(to: url, options: writeOptions)
|
|
154
|
+
} catch {
|
|
155
|
+
throw EventStoreUnavailable(underlying: error)
|
|
156
|
+
}
|
|
157
|
+
loaded = events
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
package struct LatLng: Sendable, Hashable, Codable {
|
|
4
|
+
package let latitude: Double
|
|
5
|
+
package let longitude: Double
|
|
6
|
+
|
|
7
|
+
package init(_ latitude: Double, _ longitude: Double) {
|
|
8
|
+
self.latitude = latitude
|
|
9
|
+
self.longitude = longitude
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/// Where the device is, as a location fix gives it. `timeMillis` is on the device's clock.
|
|
14
|
+
package struct Fix: Sendable, Hashable, Codable {
|
|
15
|
+
package let position: LatLng
|
|
16
|
+
package let accuracyMeters: Double?
|
|
17
|
+
package let timeMillis: Int64
|
|
18
|
+
|
|
19
|
+
package init(_ position: LatLng, accuracyMeters: Double?, timeMillis: Int64) {
|
|
20
|
+
self.position = position
|
|
21
|
+
self.accuracyMeters = accuracyMeters
|
|
22
|
+
self.timeMillis = timeMillis
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// One geofence from GET /geofences. A polygon is watched through the circle around it
|
|
27
|
+
/// (`center`, `radiusMeters`); only being inside the polygon itself counts as being there.
|
|
28
|
+
package struct Geofence: Sendable, Hashable, Codable {
|
|
29
|
+
package let id: String
|
|
30
|
+
package let name: String
|
|
31
|
+
package let center: LatLng
|
|
32
|
+
package let radiusMeters: Int
|
|
33
|
+
/// Nil for a circle. A ring, not closed: the last point joins the first.
|
|
34
|
+
package let polygon: [LatLng]?
|
|
35
|
+
package let reportsEnter: Bool
|
|
36
|
+
package let reportsExit: Bool
|
|
37
|
+
|
|
38
|
+
package init(id: String, name: String, center: LatLng, radiusMeters: Int, polygon: [LatLng]?, reportsEnter: Bool, reportsExit: Bool) {
|
|
39
|
+
self.id = id
|
|
40
|
+
self.name = name
|
|
41
|
+
self.center = center
|
|
42
|
+
self.radiusMeters = radiusMeters
|
|
43
|
+
self.polygon = polygon
|
|
44
|
+
self.reportsEnter = reportsEnter
|
|
45
|
+
self.reportsExit = reportsExit
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
package var isPolygon: Bool { polygon != nil }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// What GET /geofences last returned, with where and when it was asked from: the device asks
|
|
52
|
+
/// again after `refreshSeconds`, or once it has moved `refreshDistanceMeters` from `origin`.
|
|
53
|
+
package struct GeofenceSet: Sendable, Hashable, Codable {
|
|
54
|
+
package var geofences: [Geofence]
|
|
55
|
+
package var refreshSeconds: Int64
|
|
56
|
+
package var refreshDistanceMeters: Int
|
|
57
|
+
package var origin: LatLng
|
|
58
|
+
/// On the server's clock.
|
|
59
|
+
package var fetchedAtSeconds: Int64
|
|
60
|
+
package var etag: String?
|
|
61
|
+
|
|
62
|
+
package init(geofences: [Geofence], refreshSeconds: Int64, refreshDistanceMeters: Int, origin: LatLng, fetchedAtSeconds: Int64, etag: String?) {
|
|
63
|
+
self.geofences = geofences
|
|
64
|
+
self.refreshSeconds = refreshSeconds
|
|
65
|
+
self.refreshDistanceMeters = refreshDistanceMeters
|
|
66
|
+
self.origin = origin
|
|
67
|
+
self.fetchedAtSeconds = fetchedAtSeconds
|
|
68
|
+
self.etag = etag
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// From a GET /geofences 200 body; nil when it isn't one. Geofences this SDK can't watch (an
|
|
72
|
+
/// unknown shape, a ring of fewer than three points) are left out.
|
|
73
|
+
package static func fromResponse(_ body: Data, origin: LatLng, fetchedAtSeconds: Int64, etag: String?) -> GeofenceSet? {
|
|
74
|
+
guard let response = try? JSONDecoder().decode(GeofencesResponse.self, from: body) else { return nil }
|
|
75
|
+
return GeofenceSet(
|
|
76
|
+
geofences: response.data.compactMap(\.geofence),
|
|
77
|
+
refreshSeconds: response.meta.refresh_seconds,
|
|
78
|
+
refreshDistanceMeters: response.meta.refresh_distance_meters,
|
|
79
|
+
origin: origin,
|
|
80
|
+
fetchedAtSeconds: fetchedAtSeconds,
|
|
81
|
+
etag: etag
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// GET /geofences as the contract describes it (contracts/v1: Geofence, GeofencesMeta).
|
|
87
|
+
private struct GeofencesResponse: Decodable {
|
|
88
|
+
let data: [Item]
|
|
89
|
+
let meta: Meta
|
|
90
|
+
|
|
91
|
+
struct Meta: Decodable {
|
|
92
|
+
let refresh_seconds: Int64
|
|
93
|
+
let refresh_distance_meters: Int
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
struct Item: Decodable {
|
|
97
|
+
let id: String
|
|
98
|
+
let name: String?
|
|
99
|
+
let shape: String
|
|
100
|
+
let center: LatLng
|
|
101
|
+
let radius_meters: Int
|
|
102
|
+
let polygon: [LatLng]?
|
|
103
|
+
let triggers: [String]
|
|
104
|
+
|
|
105
|
+
var geofence: Geofence? {
|
|
106
|
+
let ring: [LatLng]?
|
|
107
|
+
switch shape {
|
|
108
|
+
case "circle":
|
|
109
|
+
ring = nil
|
|
110
|
+
case "polygon":
|
|
111
|
+
guard let polygon, polygon.count >= 3 else { return nil }
|
|
112
|
+
ring = polygon
|
|
113
|
+
default:
|
|
114
|
+
return nil
|
|
115
|
+
}
|
|
116
|
+
return Geofence(
|
|
117
|
+
id: id,
|
|
118
|
+
name: name ?? "",
|
|
119
|
+
center: center,
|
|
120
|
+
radiusMeters: radius_meters,
|
|
121
|
+
polygon: ring,
|
|
122
|
+
reportsEnter: triggers.contains("enter"),
|
|
123
|
+
reportsExit: triggers.contains("exit")
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
package enum Geo {
|
|
130
|
+
private static let earthRadiusMeters = 6_371_008.8
|
|
131
|
+
|
|
132
|
+
/// Great-circle distance (haversine).
|
|
133
|
+
package static func distanceMeters(_ a: LatLng, _ b: LatLng) -> Double {
|
|
134
|
+
let radians = Double.pi / 180
|
|
135
|
+
let dLat = (b.latitude - a.latitude) * radians
|
|
136
|
+
let dLng = (b.longitude - a.longitude) * radians
|
|
137
|
+
let sinLat = sin(dLat / 2)
|
|
138
|
+
let sinLng = sin(dLng / 2)
|
|
139
|
+
let h = sinLat * sinLat + cos(a.latitude * radians) * cos(b.latitude * radians) * sinLng * sinLng
|
|
140
|
+
return 2 * earthRadiusMeters * asin(min(1, h.squareRoot()))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// Whether `point` is inside `ring` (even-odd ray casting on latitude/longitude). Exact enough
|
|
144
|
+
/// at geofence scale; not for rings that cross the antimeridian or a pole, which a shop or a
|
|
145
|
+
/// venue never does.
|
|
146
|
+
package static func contains(_ ring: [LatLng], _ point: LatLng) -> Bool {
|
|
147
|
+
var inside = false
|
|
148
|
+
var j = ring.count - 1
|
|
149
|
+
for i in ring.indices {
|
|
150
|
+
let a = ring[i]
|
|
151
|
+
let b = ring[j]
|
|
152
|
+
if (a.latitude > point.latitude) != (b.latitude > point.latitude) {
|
|
153
|
+
let crossing = (b.longitude - a.longitude) * (point.latitude - a.latitude) / (b.latitude - a.latitude) + a.longitude
|
|
154
|
+
if point.longitude < crossing { inside.toggle() }
|
|
155
|
+
}
|
|
156
|
+
j = i
|
|
157
|
+
}
|
|
158
|
+
return inside
|
|
159
|
+
}
|
|
160
|
+
}
|