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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/BubblReactNativeSdk.podspec +29 -9
  2. package/CHANGELOG.md +52 -1
  3. package/LICENSE +202 -1
  4. package/README.md +172 -83
  5. package/android/build.gradle +33 -30
  6. package/android/src/main/AndroidManifest.xml +1 -0
  7. package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
  8. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
  9. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
  10. package/app.plugin.js +2 -0
  11. package/ios/BubblPush.swift +44 -0
  12. package/ios/BubblSdk.h +7 -0
  13. package/ios/BubblSdk.mm +252 -0
  14. package/ios/BubblSdkImpl.swift +335 -0
  15. package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
  16. package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
  17. package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
  18. package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
  19. package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
  20. package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
  21. package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
  22. package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
  23. package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
  24. package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
  25. package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
  26. package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
  27. package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
  28. package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
  29. package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
  30. package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
  31. package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
  32. package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
  33. package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
  34. package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
  35. package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
  36. package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  37. package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
  38. package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
  39. package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
  40. package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
  41. package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
  42. package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
  43. package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
  44. package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
  45. package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
  46. package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
  47. package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
  48. package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
  49. package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
  50. package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
  51. package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
  52. package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
  53. package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
  54. package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
  55. package/ios-sdk/BubblCore/API/Http.swift +39 -0
  56. package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
  57. package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
  58. package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
  59. package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
  60. package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
  61. package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
  62. package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
  63. package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
  64. package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
  65. package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
  66. package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
  67. package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
  68. package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
  69. package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
  70. package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
  71. package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  72. package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
  73. package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
  74. package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
  75. package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
  76. package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
  77. package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
  78. package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
  79. package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
  80. package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
  81. package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
  82. package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
  83. package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
  84. package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
  85. package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
  86. package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
  87. package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
  88. package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
  89. package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
  90. package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
  91. package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
  92. package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
  93. package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
  94. package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
  95. package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
  96. package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
  97. package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
  98. package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
  99. package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
  100. package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
  101. package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
  102. package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
  103. package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
  104. package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
  105. package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
  106. package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
  107. package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
  108. package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
  109. package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
  110. package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
  111. package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
  112. package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
  113. package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
  114. package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
  115. package/jest/index.js +75 -0
  116. package/lib/commonjs/BubblModule.js +14 -0
  117. package/lib/commonjs/BubblModule.js.map +1 -0
  118. package/lib/commonjs/BubblModule.web.js +13 -0
  119. package/lib/commonjs/BubblModule.web.js.map +1 -0
  120. package/lib/commonjs/NativeBubblSdk.js +20 -0
  121. package/lib/commonjs/NativeBubblSdk.js.map +1 -0
  122. package/lib/commonjs/index.js +336 -0
  123. package/lib/commonjs/index.js.map +1 -0
  124. package/lib/commonjs/package.json +1 -0
  125. package/lib/commonjs/types.js +2 -0
  126. package/lib/commonjs/types.js.map +1 -0
  127. package/lib/module/BubblModule.js +5 -0
  128. package/lib/module/BubblModule.js.map +1 -0
  129. package/lib/module/BubblModule.web.js +9 -0
  130. package/lib/module/BubblModule.web.js.map +1 -0
  131. package/lib/module/NativeBubblSdk.js +18 -0
  132. package/lib/module/NativeBubblSdk.js.map +1 -0
  133. package/lib/module/index.js +331 -0
  134. package/lib/module/index.js.map +1 -0
  135. package/lib/module/package.json +1 -0
  136. package/lib/module/types.js +2 -0
  137. package/lib/module/types.js.map +1 -0
  138. package/lib/typescript/commonjs/package.json +1 -0
  139. package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
  140. package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
  142. package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
  143. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
  144. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
  145. package/lib/typescript/commonjs/src/index.d.ts +111 -0
  146. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  147. package/lib/typescript/commonjs/src/types.d.ts +192 -0
  148. package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
  149. package/lib/typescript/module/package.json +1 -0
  150. package/lib/typescript/module/src/BubblModule.d.ts +3 -0
  151. package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
  152. package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
  153. package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
  154. package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
  155. package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
  156. package/lib/typescript/module/src/index.d.ts +111 -0
  157. package/lib/typescript/module/src/index.d.ts.map +1 -0
  158. package/lib/typescript/module/src/types.d.ts +192 -0
  159. package/lib/typescript/module/src/types.d.ts.map +1 -0
  160. package/package.json +105 -24
  161. package/plugin/build/imageNotifications.d.ts +35 -0
  162. package/plugin/build/imageNotifications.js +198 -0
  163. package/plugin/build/index.d.ts +84 -0
  164. package/plugin/build/index.js +176 -0
  165. package/src/BubblModule.ts +2 -0
  166. package/src/BubblModule.web.ts +9 -0
  167. package/src/NativeBubblSdk.ts +67 -0
  168. package/src/index.ts +338 -282
  169. package/src/types.ts +182 -0
  170. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
  171. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
  172. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
  173. package/ios/BubblSdk.swift +0 -802
  174. package/ios/BubblSdkBridge.m +0 -86
  175. package/react-native.config.js +0 -11
  176. package/src/specs/NativeBubblSdk.ts +0 -54
@@ -0,0 +1,239 @@
1
+ import Foundation
2
+
3
+ /// Why a request wasn't made, other than having no network. Both mean "later": nothing was
4
+ /// refused, and the install keeps its identity.
5
+ package enum DeviceApiError: Error {
6
+ /// The credential can't be read yet (the device hasn't been unlocked since it started).
7
+ /// Registering now would replace a working credential, so the request waits instead.
8
+ case credentialsLocked
9
+ /// POST /installs succeeded but its credential couldn't be kept, so it doesn't count.
10
+ case credentialNotSaved(any Error)
11
+ }
12
+
13
+ /// The engine's client for the device API v1 (contracts/v1/device-api-v1.yaml): registers the
14
+ /// install, signs every other request with its credential, and deals with the errors that have
15
+ /// one right answer, so callers only see the rest:
16
+ ///
17
+ /// - no credential yet: POST /installs first;
18
+ /// - timestamp_out_of_range: take the server's clock, retry once;
19
+ /// - invalid_key / invalid_signature / credential_revoked: register again, once, then retry
20
+ /// once (a second failure is returned as it is, so this can never loop).
21
+ ///
22
+ /// Every response's Date header keeps the clock in step. Registering is serialised: requests
23
+ /// that find the credential missing or refused wait for one registration rather than each
24
+ /// doing one (found on a device: sync, config and geofences all start at once on first launch).
25
+ package final class DeviceApiClient: Sendable {
26
+ private let baseUrl: String
27
+ /// Nil for a device started with a credential issued outside the app: it never registers.
28
+ private let apiKey: String?
29
+ private let onCredentialRejected: @Sendable () -> Void
30
+ private let userAgent: String
31
+ private let http: any HttpClient
32
+ private let credentials: any CredentialStore
33
+ private let clock: ServerClock
34
+ private let installBody: @Sendable () throws -> [String: Any]
35
+ private let onRegistered: @Sendable ([String: Any]) -> Void
36
+ private let registration = AsyncMutex()
37
+
38
+ /// - Parameters:
39
+ /// - installBody: what POST /installs sends (api_key is added here): the install id the SDK
40
+ /// keeps for this installation, and what the device says about itself. It throws when the
41
+ /// install id can't be read yet (before the first unlock): nothing is sent then.
42
+ /// - onRegistered: told the install response's `data` (device and config) after each
43
+ /// successful registration.
44
+ package init(
45
+ baseUrl: String,
46
+ apiKey: String?,
47
+ sdkVersion: String,
48
+ http: any HttpClient,
49
+ credentials: any CredentialStore,
50
+ clock: ServerClock,
51
+ installBody: @escaping @Sendable () throws -> [String: Any],
52
+ onRegistered: @escaping @Sendable ([String: Any]) -> Void = { _ in },
53
+ onCredentialRejected: @escaping @Sendable () -> Void = {}
54
+ ) {
55
+ var trimmed = baseUrl
56
+ while trimmed.hasSuffix("/") { trimmed.removeLast() }
57
+ self.baseUrl = trimmed
58
+ self.apiKey = apiKey
59
+ userAgent = "Bubbl-iOS-SDK/\(sdkVersion)"
60
+ self.http = http
61
+ self.credentials = credentials
62
+ self.clock = clock
63
+ self.installBody = installBody
64
+ self.onRegistered = onRegistered
65
+ self.onCredentialRejected = onCredentialRejected
66
+ }
67
+
68
+ /// POST /installs: registers (or registers again) and keeps the credential it returns.
69
+ /// Unsigned; the public API key identifies the company. The response tells the caller why
70
+ /// when it failed (e.g. own_app_not_in_plan, or a wrong API key).
71
+ package func register() async throws -> ApiResponse {
72
+ try await registration.withLock { try await self.registerLocked() }
73
+ }
74
+
75
+ /// A signed request to `path` (from the API root, no leading slash: "api/v1/geofences").
76
+ /// `query` values are sent as given; `body` is sent and signed byte for byte; `headers` are
77
+ /// added as they are (e.g. Idempotency-Key, If-None-Match).
78
+ ///
79
+ /// Throws when the request couldn't be made at all: no response (offline), or a
80
+ /// `DeviceApiError` (the credential can't be read yet, or couldn't be kept). Either way it's
81
+ /// worth trying again later; nothing was refused.
82
+ package func request(
83
+ _ method: String,
84
+ _ path: String,
85
+ query: [String: String] = [:],
86
+ body: String? = nil,
87
+ headers: [String: String] = [:]
88
+ ) async throws -> ApiResponse {
89
+ if try credentialKeyId() == nil {
90
+ // The first to get here registers; the rest wait, find the credential, and use it.
91
+ let registered = try await registration.withLock { () async throws -> ApiResponse? in
92
+ try self.credentialKeyId() != nil ? nil : try await self.registerLocked()
93
+ }
94
+ if let registered, !registered.isSuccessful { return registered }
95
+ }
96
+
97
+ var sent = try await send(method, path, query: query, body: body, headers: headers)
98
+
99
+ if sent.response.handling?.action == .correctClockAndRetry {
100
+ if let serverTime = JSON.int64(sent.response.json?["server_time"]), serverTime > 0 {
101
+ clock.sync(serverSeconds: serverTime)
102
+ }
103
+ sent = try await send(method, path, query: query, body: body, headers: headers)
104
+ }
105
+
106
+ if sent.response.handling?.action == .reRegister {
107
+ let refusedKeyId = sent.keyId
108
+ let registered = try await registration.withLock { () async throws -> ApiResponse? in
109
+ // Another request may have registered again since this one was signed (it was
110
+ // refused for the same reason): use that credential rather than replace it.
111
+ try self.credentialKeyId() != refusedKeyId ? nil : try await self.registerLocked()
112
+ }
113
+ if let registered, !registered.isSuccessful { return registered }
114
+ sent = try await send(method, path, query: query, body: body, headers: headers)
115
+ }
116
+
117
+ return sent.response
118
+ }
119
+
120
+ /// The credential's key id, nil when there's none; throws when it can't be read yet (never
121
+ /// "none" for a locked Keychain, or a working credential would be replaced).
122
+ private func credentialKeyId() throws -> String? {
123
+ switch credentials.read() {
124
+ case .present(let credential): credential.keyId
125
+ case .missing: nil
126
+ case .unavailable: throw DeviceApiError.credentialsLocked
127
+ }
128
+ }
129
+
130
+ /// A response, and the key id the request was signed with (nil if it couldn't be signed).
131
+ private struct Sent: Sendable {
132
+ let response: ApiResponse
133
+ let keyId: String?
134
+ }
135
+
136
+ /// What a device started with a credential gets where an install would register.
137
+ static let credentialRejected = #"{"message":"The device's credential was refused: start Bubbl with a new one.","code":"credential_rejected"}"#
138
+
139
+ private func registerLocked() async throws -> ApiResponse {
140
+ // Started with a credential issued outside the app: nothing here can renew it (there's no
141
+ // API key), so where an install would register, the app is told and has to start Bubbl
142
+ // with a new one.
143
+ guard let apiKey else {
144
+ onCredentialRejected()
145
+ return ApiResponse(HttpResponse(status: 401, body: Self.credentialRejected)).handled(as: ErrorHandling(.stopAndReport))
146
+ }
147
+ var install: [String: Any]
148
+ do {
149
+ install = try installBody()
150
+ } catch {
151
+ throw DeviceApiError.credentialsLocked
152
+ }
153
+ install["api_key"] = apiKey
154
+ let body = JSON.string(install)
155
+ let response = try await execute(HttpRequest(method: "POST", url: url("api/v1/installs"), headers: jsonHeaders(body: body), body: body))
156
+
157
+ if response.isSuccessful,
158
+ let data = response.json?["data"] as? [String: Any],
159
+ let credential = data["credential"] as? [String: Any],
160
+ let keyId = credential["key_id"] as? String, !keyId.isEmpty,
161
+ let secret = credential["secret"] as? String, !secret.isEmpty {
162
+ // Not kept, not registered: the next request registers again rather than carry on
163
+ // with a credential this install no longer has.
164
+ do {
165
+ try credentials.save(keyId: keyId, secret: secret)
166
+ } catch {
167
+ throw DeviceApiError.credentialNotSaved(error)
168
+ }
169
+ onRegistered(data)
170
+ }
171
+
172
+ // invalid_key here means the public API key itself is wrong: registering again can't fix
173
+ // that, so it's reported rather than retried.
174
+ if response.handling?.action == .reRegister {
175
+ return response.handled(as: ErrorHandling(.stopAndReport))
176
+ }
177
+ return response
178
+ }
179
+
180
+ private func send(_ method: String, _ path: String, query: [String: String], body: String?, headers: [String: String]) async throws -> Sent {
181
+ let credential: SigningCredential
182
+ switch credentials.read() {
183
+ case .present(let current):
184
+ credential = current
185
+ case .missing:
186
+ // The credential vanished (cleared mid-request): the server's answer would be
187
+ // missing_signature, so say that without a round trip.
188
+ return Sent(response: ApiResponse(HttpResponse(status: 401, body: #"{"message":"Not registered.","code":"missing_signature"}"#)), keyId: nil)
189
+ case .unavailable:
190
+ throw DeviceApiError.credentialsLocked
191
+ }
192
+
193
+ let fullUrl = url(path, query: query)
194
+ let timestamp = clock.nowSeconds()
195
+ // The server signs the request path it sees, which includes any prefix in the base URL.
196
+ let signedPath = String((URLComponents(string: fullUrl)?.percentEncodedPath ?? "").drop { $0 == "/" })
197
+ let signature = RequestSigner.sign(secret: credential.secret, method: method, path: signedPath, timestamp: Int(timestamp), body: body ?? "", query: query)
198
+
199
+ var allHeaders = jsonHeaders(body: body)
200
+ allHeaders.merge(headers) { _, new in new }
201
+ allHeaders["X-Bubbl-Key-Id"] = credential.keyId
202
+ allHeaders["X-Bubbl-Timestamp"] = String(timestamp)
203
+ allHeaders["X-Bubbl-Signature"] = signature
204
+
205
+ let response = try await execute(HttpRequest(method: method, url: fullUrl, headers: allHeaders, body: body))
206
+ return Sent(response: response, keyId: credential.keyId)
207
+ }
208
+
209
+ private func execute(_ request: HttpRequest) async throws -> ApiResponse {
210
+ let response = try await http.execute(request)
211
+ clock.sync(dateHeader: response.header("Date"))
212
+ let result = ApiResponse(response)
213
+ if let said = Self.wrongEnvironment(result) { BubblLog.error(said) }
214
+ return result
215
+ }
216
+
217
+ /// What the log says for wrong_environment (a pk_test_ key at the Production address, or the
218
+ /// other way round): the server's message, and the address the key belongs to (`base_url`).
219
+ package static func wrongEnvironment(_ response: ApiResponse) -> String? {
220
+ guard response.code == "wrong_environment" else { return nil }
221
+ let json = response.json
222
+ let message = (json?["message"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "The API key and the address are for different workspaces."
223
+ let address = (json?["base_url"] as? String).flatMap { $0.isEmpty ? nil : $0 }
224
+ return "Bubbl: the API key and baseUrl don't match (wrong_environment). \(message)" + (address.map { " Use baseUrl \($0) with this key." } ?? "")
225
+ }
226
+
227
+ /// The URL for `path`, with `query` in canonical order (the same encoding that's signed).
228
+ private func url(_ path: String, query: [String: String] = [:]) -> String {
229
+ let canonical = RequestSigner.canonicalQuery(query)
230
+ let relative = path.hasPrefix("/") ? String(path.dropFirst()) : path
231
+ return "\(baseUrl)/\(relative)" + (canonical.isEmpty ? "" : "?\(canonical)")
232
+ }
233
+
234
+ private func jsonHeaders(body: String?) -> [String: String] {
235
+ var headers = ["Accept": "application/json", "User-Agent": userAgent]
236
+ if body != nil { headers["Content-Type"] = "application/json" }
237
+ return headers
238
+ }
239
+ }
@@ -0,0 +1,68 @@
1
+ /// What the engine does about a failed request. The device API contract's errors fixture
2
+ /// (contracts/v1/fixtures/v1/errors.json) is the source of truth for which code gets which
3
+ /// action, and ErrorActionsContractTests checks this against it. Raw values are the fixture's.
4
+ package enum ErrorAction: String, Sendable, CaseIterable {
5
+ /// POST /installs again, once and one at a time, then retry.
6
+ case reRegister = "re_register"
7
+ /// Take the clock offset from server_time (or the Date header) and retry once.
8
+ case correctClockAndRetry = "correct_clock_and_retry"
9
+ /// Stop calling the API for a while (ErrorHandling.pauseHours); keep queuing.
10
+ case pause
11
+ /// PUT /device, then retry.
12
+ case describeDevice = "describe_device"
13
+ /// Drop the item and fetch geofences again: the location is gone.
14
+ case dropAndRefreshGeofences = "drop_and_refresh_geofences"
15
+ /// Discard the request or item (it can never succeed as sent) and log it.
16
+ case drop
17
+ /// Wait for Retry-After, then retry.
18
+ case waitAndRetry = "wait_and_retry"
19
+ /// Stop and surface it in diagnostics: something is misconfigured.
20
+ case stopAndReport = "stop_and_report"
21
+ /// The pairing UI shows the server's message.
22
+ case showMessage = "show_message"
23
+ /// 5xx or network failure: exponential backoff with jitter.
24
+ case backoff
25
+ }
26
+
27
+ /// An ErrorAction, with how long to pause for `.pause`.
28
+ package struct ErrorHandling: Sendable, Equatable {
29
+ package let action: ErrorAction
30
+ package let pauseHours: Int?
31
+
32
+ package init(_ action: ErrorAction, pauseHours: Int? = nil) {
33
+ self.action = action
34
+ self.pauseHours = pauseHours
35
+ }
36
+ }
37
+
38
+ package enum ErrorActions {
39
+ /// The handling for a response's status and error `code` (nil when the body had none). An
40
+ /// unknown code falls back on its status, so a newer server can't wedge an older SDK.
41
+ package static func forResponse(status: Int, code: String?) -> ErrorHandling {
42
+ switch code {
43
+ case "missing_signature": ErrorHandling(.stopAndReport)
44
+ case "timestamp_out_of_range": ErrorHandling(.correctClockAndRetry)
45
+ case "invalid_key", "invalid_signature", "credential_revoked": ErrorHandling(.reRegister)
46
+ case "workspace_paused": ErrorHandling(.pause, pauseHours: 6)
47
+ case "own_app_not_in_plan": ErrorHandling(.pause, pauseHours: 24)
48
+ // Sandbox: no room for another device awaiting approval; back off as for a paused workspace.
49
+ case "sandbox_pending_full": ErrorHandling(.pause, pauseHours: 6)
50
+ // A Sandbox key at the Production address or the other way round: only the app can fix it.
51
+ case "wrong_environment": ErrorHandling(.stopAndReport)
52
+ case "showcase_only", "unknown_notification": ErrorHandling(.drop)
53
+ case "device_not_registered": ErrorHandling(.describeDevice)
54
+ case "request_in_progress", "too_many_failed_attempts": ErrorHandling(.waitAndRetry)
55
+ case "unknown_location": ErrorHandling(.dropAndRefreshGeofences)
56
+ // The message goes back to whoever asked (pairing; Bubbl.registerTestDevice).
57
+ case "invalid_pairing_code", "invalid_test_device_code", "test_devices_full": ErrorHandling(.showMessage)
58
+ case "rate_limited": ErrorHandling(.waitAndRetry)
59
+ default:
60
+ switch status {
61
+ case 422: ErrorHandling(.drop)
62
+ case 429: ErrorHandling(.waitAndRetry)
63
+ case 500...: ErrorHandling(.backoff)
64
+ default: ErrorHandling(.stopAndReport)
65
+ }
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,39 @@
1
+ /// One HTTP request as the engine sends it: the full URL, and the body's exact text.
2
+ package struct HttpRequest: Sendable, Equatable {
3
+ package var method: String
4
+ package var url: String
5
+ package var headers: [String: String]
6
+ package var body: String?
7
+
8
+ package init(method: String, url: String, headers: [String: String] = [:], body: String? = nil) {
9
+ self.method = method
10
+ self.url = url
11
+ self.headers = headers
12
+ self.body = body
13
+ }
14
+ }
15
+
16
+ /// The response: status, body text and headers (names as the server sent them).
17
+ package struct HttpResponse: Sendable, Equatable {
18
+ package var status: Int
19
+ package var body: String
20
+ package var headers: [String: [String]]
21
+
22
+ package init(status: Int, body: String, headers: [String: [String]] = [:]) {
23
+ self.status = status
24
+ self.body = body
25
+ self.headers = headers
26
+ }
27
+
28
+ /// A header's first value, matched without regard to case (HTTP header names aren't).
29
+ package func header(_ name: String) -> String? {
30
+ headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value.first
31
+ }
32
+ }
33
+
34
+ /// Sends one request. The engine's only way out to the network, so tests swap it for a fake
35
+ /// and no HTTP library is forced on the app. Throws when there's no response at all (offline,
36
+ /// timed out); any HTTP status is a response.
37
+ package protocol HttpClient: Sendable {
38
+ func execute(_ request: HttpRequest) async throws -> HttpResponse
39
+ }
@@ -0,0 +1,69 @@
1
+ import Foundation
2
+
3
+ /// The server's idea of "now", for request timestamps: a signed request is refused when its
4
+ /// timestamp is more than 300 s from the server's clock, and phone clocks drift or are set by
5
+ /// hand. The offset comes from each response's Date header, and from the server_time a
6
+ /// timestamp_out_of_range error carries.
7
+ package final class ServerClock: @unchecked Sendable {
8
+ /// Offset changes smaller than this are ignored: the Date header has one-second resolution
9
+ /// and a response takes time to arrive.
10
+ private static let toleranceSeconds: Int64 = 5
11
+
12
+ private let lock = NSLock()
13
+ private var offset: Int64
14
+ private let deviceNowSeconds: @Sendable () -> Int64
15
+ private let onChange: @Sendable (Int64) -> Void
16
+
17
+ /// - Parameter onChange: told each new offset, so it can be kept across launches.
18
+ package init(
19
+ initialOffsetSeconds: Int64 = 0,
20
+ deviceNowSeconds: @escaping @Sendable () -> Int64 = { Int64(Date().timeIntervalSince1970) },
21
+ onChange: @escaping @Sendable (Int64) -> Void = { _ in }
22
+ ) {
23
+ offset = initialOffsetSeconds
24
+ self.deviceNowSeconds = deviceNowSeconds
25
+ self.onChange = onChange
26
+ }
27
+
28
+ package var offsetSeconds: Int64 {
29
+ lock.sync { offset }
30
+ }
31
+
32
+ /// Unix seconds as the server would read them now.
33
+ package func nowSeconds() -> Int64 {
34
+ deviceNowSeconds() + offsetSeconds
35
+ }
36
+
37
+ /// Adopt the server's time.
38
+ package func sync(serverSeconds: Int64) {
39
+ let candidate = serverSeconds - deviceNowSeconds()
40
+ let changed: Bool = lock.sync {
41
+ guard abs(candidate - offset) >= Self.toleranceSeconds else { return false }
42
+ offset = candidate
43
+ return true
44
+ }
45
+ if changed { onChange(candidate) }
46
+ }
47
+
48
+ /// sync(serverSeconds:) from an HTTP Date header (RFC 1123); an absent or unreadable one
49
+ /// changes nothing.
50
+ package func sync(dateHeader: String?) {
51
+ guard let dateHeader, let date = Self.httpDate(dateHeader) else { return }
52
+ sync(serverSeconds: Int64(date.timeIntervalSince1970))
53
+ }
54
+
55
+ /// Parses "Sun, 06 Nov 1994 08:49:37 GMT". One formatter for every response, used one at a
56
+ /// time (DateFormatter isn't safe to share across threads everywhere the core runs).
57
+ package static func httpDate(_ value: String) -> Date? {
58
+ httpDateLock.sync { httpDateFormatter.date(from: value.trimmingCharacters(in: .whitespaces)) }
59
+ }
60
+
61
+ private static let httpDateLock = NSLock()
62
+ private static let httpDateFormatter: DateFormatter = {
63
+ let formatter = DateFormatter()
64
+ formatter.locale = Locale(identifier: "en_US_POSIX")
65
+ formatter.timeZone = TimeZone(identifier: "GMT")
66
+ formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
67
+ return formatter
68
+ }()
69
+ }
@@ -0,0 +1,5 @@
1
+ /// The SDK's version, sent as X-Bubbl-SDK-Version. Stamped from version.txt by
2
+ /// scripts/sync-version.mjs; don't edit it by hand.
3
+ package enum BubblVersion {
4
+ package static let sdk = "5.0.0-alpha.2"
5
+ }
@@ -0,0 +1,216 @@
1
+ import Foundation
2
+
3
+ /// GET /config's data (every field is always present, per the contract), read leniently.
4
+ package struct SdkConfig: Sendable, Hashable {
5
+ package static let defaultRefreshSeconds: Int64 = 3_600
6
+
7
+ package let json: JSONValue
8
+
9
+ package init(_ json: JSONValue) {
10
+ self.json = json
11
+ }
12
+
13
+ package var enabled: Bool {
14
+ if case .bool(let value)? = json["enabled"] { value } else { true }
15
+ }
16
+
17
+ /// Whether the server has this device's push token (device.push_token_registered). False while
18
+ /// the app holds one means the server dropped it (Apple or FCM called it dead, or it was sent
19
+ /// for the wrong APNs environment); nil before the device has described itself.
20
+ package var pushTokenRegistered: Bool? {
21
+ if case .bool(let value)? = json["device"]?["push_token_registered"] { value } else { nil }
22
+ }
23
+
24
+ package var minimumVersion: String? { json["sdk"]?["minimum_version"]?.nonEmptyString }
25
+
26
+ package var logLevel: String { json["sdk"]?["log_level"]?.nonEmptyString ?? "warning" }
27
+
28
+ /// From one minute to a week, whatever the server says.
29
+ package var configRefreshSeconds: Int64 {
30
+ json["sync"]?["config_refresh_seconds"]?.int64(in: 60...604_800) ?? Self.defaultRefreshSeconds
31
+ }
32
+
33
+ /// From 1 to the queue's 100.
34
+ package var maxEventsPerRequest: Int {
35
+ json["sync"]?["max_events_per_request"]?.int64(in: 1...Int64(EventQueue.batchSize)).map { Int($0) } ?? EventQueue.batchSize
36
+ }
37
+
38
+ /// The dashboard's privacy view: when to explain before asking for a permission.
39
+ package var privacyNotice: String {
40
+ let notice = json["privacy"]?["notice"]?.stringValue ?? ""
41
+ return ["automatic", "always", "never"].contains(notice) ? notice : "automatic"
42
+ }
43
+
44
+ /// The dashboard's own words for the privacy view, if it has set any.
45
+ package var privacyText: String? {
46
+ json["privacy"]?["text"]?.nonEmptyString.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 }
47
+ }
48
+
49
+ /// The workspace's privacy policy, linked from the privacy view.
50
+ package var privacyUrl: String? {
51
+ json["privacy"]?["callback_url"]?.nonEmptyString.flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 }
52
+ }
53
+
54
+ /// Whether `sdkVersion` is at least the minimum the workspace allows (none: any).
55
+ package func supports(_ sdkVersion: String) -> Bool {
56
+ guard let minimumVersion else { return true }
57
+ return Versions.compare(sdkVersion, minimumVersion) >= 0
58
+ }
59
+ }
60
+
61
+ package enum ConfigResult: Sendable, Equatable {
62
+ case updated
63
+ case unchanged
64
+ case notDue
65
+ case failed(ApiFailure)
66
+ }
67
+
68
+ /// The runtime configuration: what POST /installs returned, then GET /config every
69
+ /// config_refresh_seconds (conditional, If-None-Match). Kept on disk, so the engine starts with the
70
+ /// last known configuration when offline.
71
+ package final class ConfigSync: Sendable {
72
+ package struct Saved: Codable, Sendable, Equatable {
73
+ package var config: JSONValue
74
+ package var etag: String?
75
+ package var fetchedAtSeconds: Int64
76
+ }
77
+
78
+ private let api: DeviceApiClient
79
+ private let clock: ServerClock
80
+ private let store: any ValueStore<Saved>
81
+ private let lock = AsyncMutex()
82
+ /// The saved config, once read (it's asked for often: enabled, the log level, the batch size).
83
+ private let cache = Cached<Saved>()
84
+
85
+ package init(api: DeviceApiClient, clock: ServerClock, store: any ValueStore<Saved>) {
86
+ self.api = api
87
+ self.clock = clock
88
+ self.store = store
89
+ }
90
+
91
+ /// The last known configuration; nil before the first, or while it can't be read.
92
+ package var current: SdkConfig? {
93
+ (try? load())?.map { SdkConfig($0.config) }
94
+ }
95
+
96
+ /// The config that came with a registration.
97
+ package func registered(_ config: JSONValue) throws {
98
+ try save(Saved(config: config, etag: nil, fetchedAtSeconds: clock.nowSeconds()))
99
+ }
100
+
101
+ /// The workspace block from POST /test-device, put into the saved config (where GET /config
102
+ /// would bring it), so the device reads as approved without another request.
103
+ package func setWorkspace(_ workspace: JSONValue) async throws {
104
+ try await lock.withLock {
105
+ guard var saved = try self.load(), case .object(var fields) = saved.config else { return }
106
+ fields["workspace"] = workspace
107
+ saved.config = .object(fields)
108
+ try self.save(saved)
109
+ }
110
+ }
111
+
112
+ /// Forget it (deleteMyData): the next registration brings the config afresh.
113
+ package func forget() throws {
114
+ try store.delete()
115
+ cache.set(nil)
116
+ }
117
+
118
+ private func load() throws -> Saved? {
119
+ try cache.value { try store.load() }
120
+ }
121
+
122
+ private func save(_ saved: Saved) throws {
123
+ try store.save(saved)
124
+ cache.set(saved)
125
+ }
126
+
127
+ /// GET /config when it's due: every config_refresh_seconds, or sooner with `maxAgeSeconds` (the
128
+ /// app coming to the front, so a change such as a Sandbox approval is seen on the next open).
129
+ /// Conditional, so an unchanged config costs a 304.
130
+ package func refresh(force: Bool = false, maxAgeSeconds: Int64? = nil) async -> ConfigResult {
131
+ await lock.withLock { await self.refreshLocked(force: force, maxAgeSeconds: maxAgeSeconds) }
132
+ }
133
+
134
+ private func refreshLocked(force: Bool, maxAgeSeconds: Int64?) async -> ConfigResult {
135
+ let saved: Saved?
136
+ do {
137
+ saved = try load()
138
+ } catch {
139
+ return .failed(.backoff)
140
+ }
141
+ let refreshSeconds = SdkConfig(saved?.config ?? .null).configRefreshSeconds
142
+ if !force, let saved, clock.nowSeconds() - saved.fetchedAtSeconds < min(refreshSeconds, maxAgeSeconds ?? refreshSeconds) {
143
+ return .notDue
144
+ }
145
+
146
+ let response: ApiResponse
147
+ do {
148
+ response = try await api.request("GET", "api/v1/config", headers: saved?.etag.map { ["If-None-Match": $0] } ?? [:])
149
+ } catch {
150
+ return .failed(.backoff)
151
+ }
152
+
153
+ do {
154
+ if response.status == 304, var saved {
155
+ saved.fetchedAtSeconds = clock.nowSeconds()
156
+ try save(saved)
157
+ return .unchanged
158
+ }
159
+ struct Body: Decodable { let data: JSONValue }
160
+ if response.status == 200, let body = response.decode(Body.self), case .object = body.data {
161
+ try save(Saved(config: body.data, etag: response.header("ETag"), fetchedAtSeconds: clock.nowSeconds()))
162
+ return .updated
163
+ }
164
+ } catch {
165
+ return .failed(.backoff)
166
+ }
167
+ return response.isSuccessful ? .failed(.backoff) : .failed(ApiFailure.of(response))
168
+ }
169
+ }
170
+
171
+ /// Semantic version order: numbers compared numerically, and a pre-release before its release.
172
+ package enum Versions {
173
+ package static func compare(_ a: String, _ b: String) -> Int {
174
+ let (coreA, preA) = split(a)
175
+ let (coreB, preB) = split(b)
176
+ for i in 0..<max(coreA.count, coreB.count) {
177
+ let x = i < coreA.count ? coreA[i] : 0
178
+ let y = i < coreB.count ? coreB[i] : 0
179
+ if x != y { return x < y ? -1 : 1 }
180
+ }
181
+ switch (preA, preB) {
182
+ case (nil, nil): return 0
183
+ case (nil, _): return 1
184
+ case (_, nil): return -1
185
+ case let (a?, b?): return comparePre(a, b)
186
+ }
187
+ }
188
+
189
+ private static func split(_ version: String) -> ([Int], String?) {
190
+ var clean = version.trimmingCharacters(in: .whitespaces)
191
+ if clean.hasPrefix("v") { clean.removeFirst() }
192
+ if let plus = clean.firstIndex(of: "+") { clean = String(clean[..<plus]) }
193
+ let parts = clean.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false)
194
+ let core = parts[0].split(separator: ".").map { Int($0) ?? 0 }
195
+ let pre = parts.count > 1 && !parts[1].isEmpty ? String(parts[1]) : nil
196
+ return (core, pre)
197
+ }
198
+
199
+ private static func comparePre(_ a: String, _ b: String) -> Int {
200
+ let partsA = a.split(separator: ".")
201
+ let partsB = b.split(separator: ".")
202
+ for i in 0..<min(partsA.count, partsB.count) {
203
+ let x = Int(partsA[i])
204
+ let y = Int(partsB[i])
205
+ let diff: Int
206
+ switch (x, y) {
207
+ case let (x?, y?): diff = x == y ? 0 : (x < y ? -1 : 1)
208
+ case (_?, nil): diff = -1
209
+ case (nil, _?): diff = 1
210
+ default: diff = partsA[i] == partsB[i] ? 0 : (partsA[i] < partsB[i] ? -1 : 1)
211
+ }
212
+ if diff != 0 { return diff }
213
+ }
214
+ return partsA.count == partsB.count ? 0 : (partsA.count < partsB.count ? -1 : 1)
215
+ }
216
+ }