@bubblsdk/react-native-sdk 4.1.7 → 5.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/BubblReactNativeSdk.podspec +29 -9
  2. package/CHANGELOG.md +47 -0
  3. package/LICENSE +202 -1
  4. package/README.md +172 -83
  5. package/android/build.gradle +33 -30
  6. package/android/src/main/AndroidManifest.xml +1 -0
  7. package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
  8. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
  9. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
  10. package/app.plugin.js +2 -0
  11. package/ios/BubblPush.swift +44 -0
  12. package/ios/BubblSdk.h +7 -0
  13. package/ios/BubblSdk.mm +252 -0
  14. package/ios/BubblSdkImpl.swift +335 -0
  15. package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
  16. package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
  17. package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
  18. package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
  19. package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
  20. package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
  21. package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
  22. package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
  23. package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
  24. package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
  25. package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
  26. package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
  27. package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
  28. package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
  29. package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
  30. package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
  31. package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
  32. package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
  33. package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
  34. package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
  35. package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
  36. package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  37. package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
  38. package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
  39. package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
  40. package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
  41. package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
  42. package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
  43. package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
  44. package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
  45. package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
  46. package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
  47. package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
  48. package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
  49. package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
  50. package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
  51. package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
  52. package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
  53. package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
  54. package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
  55. package/ios-sdk/BubblCore/API/Http.swift +39 -0
  56. package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
  57. package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
  58. package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
  59. package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
  60. package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
  61. package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
  62. package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
  63. package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
  64. package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
  65. package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
  66. package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
  67. package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
  68. package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
  69. package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
  70. package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
  71. package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  72. package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
  73. package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
  74. package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
  75. package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
  76. package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
  77. package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
  78. package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
  79. package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
  80. package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
  81. package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
  82. package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
  83. package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
  84. package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
  85. package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
  86. package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
  87. package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
  88. package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
  89. package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
  90. package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
  91. package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
  92. package/ios-sdk/BubblSDK/BubblPermissions.swift +77 -0
  93. package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
  94. package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
  95. package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
  96. package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
  97. package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
  98. package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
  99. package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +318 -0
  100. package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
  101. package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
  102. package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
  103. package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
  104. package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
  105. package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
  106. package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
  107. package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +167 -0
  108. package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
  109. package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
  110. package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
  111. package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
  112. package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
  113. package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
  114. package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
  115. package/jest/index.js +75 -0
  116. package/lib/commonjs/BubblModule.js +14 -0
  117. package/lib/commonjs/BubblModule.js.map +1 -0
  118. package/lib/commonjs/BubblModule.web.js +13 -0
  119. package/lib/commonjs/BubblModule.web.js.map +1 -0
  120. package/lib/commonjs/NativeBubblSdk.js +20 -0
  121. package/lib/commonjs/NativeBubblSdk.js.map +1 -0
  122. package/lib/commonjs/index.js +336 -0
  123. package/lib/commonjs/index.js.map +1 -0
  124. package/lib/commonjs/package.json +1 -0
  125. package/lib/commonjs/types.js +2 -0
  126. package/lib/commonjs/types.js.map +1 -0
  127. package/lib/module/BubblModule.js +5 -0
  128. package/lib/module/BubblModule.js.map +1 -0
  129. package/lib/module/BubblModule.web.js +9 -0
  130. package/lib/module/BubblModule.web.js.map +1 -0
  131. package/lib/module/NativeBubblSdk.js +18 -0
  132. package/lib/module/NativeBubblSdk.js.map +1 -0
  133. package/lib/module/index.js +331 -0
  134. package/lib/module/index.js.map +1 -0
  135. package/lib/module/package.json +1 -0
  136. package/lib/module/types.js +2 -0
  137. package/lib/module/types.js.map +1 -0
  138. package/lib/typescript/commonjs/package.json +1 -0
  139. package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
  140. package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
  142. package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
  143. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
  144. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
  145. package/lib/typescript/commonjs/src/index.d.ts +111 -0
  146. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  147. package/lib/typescript/commonjs/src/types.d.ts +192 -0
  148. package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
  149. package/lib/typescript/module/package.json +1 -0
  150. package/lib/typescript/module/src/BubblModule.d.ts +3 -0
  151. package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
  152. package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
  153. package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
  154. package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
  155. package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
  156. package/lib/typescript/module/src/index.d.ts +111 -0
  157. package/lib/typescript/module/src/index.d.ts.map +1 -0
  158. package/lib/typescript/module/src/types.d.ts +192 -0
  159. package/lib/typescript/module/src/types.d.ts.map +1 -0
  160. package/package.json +105 -24
  161. package/plugin/build/imageNotifications.d.ts +35 -0
  162. package/plugin/build/imageNotifications.js +198 -0
  163. package/plugin/build/index.d.ts +84 -0
  164. package/plugin/build/index.js +176 -0
  165. package/src/BubblModule.ts +2 -0
  166. package/src/BubblModule.web.ts +9 -0
  167. package/src/NativeBubblSdk.ts +67 -0
  168. package/src/index.ts +338 -282
  169. package/src/types.ts +182 -0
  170. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
  171. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
  172. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
  173. package/ios/BubblSdk.swift +0 -802
  174. package/ios/BubblSdkBridge.m +0 -86
  175. package/react-native.config.js +0 -11
  176. package/src/specs/NativeBubblSdk.ts +0 -54
@@ -0,0 +1,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
+ }