@bubblsdk/react-native-sdk 4.1.6 → 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 +54 -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,90 @@
1
+ import Foundation
2
+
3
+ /// A device API v1 response: its status, body and headers, and — for an error — what the engine
4
+ /// should do about it (`handling`, from the contract's error table).
5
+ package struct ApiResponse: Sendable {
6
+ package let status: Int
7
+ package let body: String
8
+ private let raw: HttpResponse
9
+ private let forcedHandling: ErrorHandling?
10
+
11
+ package init(_ raw: HttpResponse, handledAs forcedHandling: ErrorHandling? = nil) {
12
+ status = raw.status
13
+ body = raw.body
14
+ self.raw = raw
15
+ self.forcedHandling = forcedHandling
16
+ }
17
+
18
+ package var isSuccessful: Bool { (200...299).contains(status) || status == 304 }
19
+
20
+ /// The body as a JSON object, or nil when it isn't one (a 304, or a proxy's error page).
21
+ package var json: [String: Any]? { JSON.object(body) }
22
+
23
+ /// The body decoded as `T`, or nil when it isn't one. Codable reads the same on every
24
+ /// platform, so parsing goes through here rather than `json`.
25
+ package func decode<T: Decodable>(_ type: T.Type) -> T? {
26
+ try? JSONDecoder().decode(type, from: Data(body.utf8))
27
+ }
28
+
29
+ /// The error's stable `code`, when the body has one.
30
+ package var code: String? {
31
+ (json?["code"] as? String).flatMap { $0.isEmpty ? nil : $0 }
32
+ }
33
+
34
+ /// What to do about a failed response; nil for a success.
35
+ package var handling: ErrorHandling? {
36
+ isSuccessful ? nil : forcedHandling ?? ErrorActions.forResponse(status: status, code: code)
37
+ }
38
+
39
+ /// The same response, handled as `handling` says instead of by its code.
40
+ package func handled(as handling: ErrorHandling) -> ApiResponse {
41
+ ApiResponse(raw, handledAs: handling)
42
+ }
43
+
44
+ package func header(_ name: String) -> String? { raw.header(name) }
45
+ }
46
+
47
+ /// Why a call to the device API didn't get through, reduced to what whoever scheduled it (a
48
+ /// background task, an app resume) has to do next. Built from the contract's error table.
49
+ package enum ApiFailure: Sendable, Equatable {
50
+ /// Try again later with exponential backoff (5xx, no network).
51
+ case backoff
52
+ /// The server asked for a wait (429, request_in_progress).
53
+ case retryAfter(seconds: Int)
54
+ /// Stop calling the API for a while (workspace paused, free tier); keep queuing.
55
+ case pause(hours: Int, code: String?)
56
+ /// The server doesn't know this device yet: PUT /device, then try again.
57
+ case describeDevice
58
+ /// The request can never succeed as sent: discard it (and refetch geofences, if said).
59
+ case drop(refreshGeofences: Bool, status: Int, code: String?)
60
+ /// Something only a developer can fix (misconfiguration): surface it in diagnostics.
61
+ case stopped(status: Int, code: String?)
62
+
63
+ private static let defaultRetrySeconds = 30
64
+ private static let defaultPauseHours = 1
65
+
66
+ /// What a failed `response` means for the caller. Never called with a success.
67
+ package static func of(_ response: ApiResponse) -> ApiFailure {
68
+ guard let handling = response.handling else { return .stopped(status: response.status, code: response.code) }
69
+
70
+ switch handling.action {
71
+ case .backoff:
72
+ return .backoff
73
+ case .waitAndRetry:
74
+ let seconds = response.header("Retry-After").flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }
75
+ return .retryAfter(seconds: max(seconds ?? defaultRetrySeconds, 1))
76
+ case .pause:
77
+ return .pause(hours: handling.pauseHours ?? defaultPauseHours, code: response.code)
78
+ case .describeDevice:
79
+ return .describeDevice
80
+ case .drop:
81
+ return .drop(refreshGeofences: false, status: response.status, code: response.code)
82
+ case .dropAndRefreshGeofences:
83
+ return .drop(refreshGeofences: true, status: response.status, code: response.code)
84
+ // The client has already registered again or corrected the clock once; what's left of
85
+ // those, and everything else, needs a person.
86
+ case .reRegister, .correctClockAndRetry, .stopAndReport, .showMessage:
87
+ return .stopped(status: response.status, code: response.code)
88
+ }
89
+ }
90
+ }
@@ -0,0 +1,116 @@
1
+ import Foundation
2
+
3
+ /// The install's credential, read together so a registration in between can't mix old and new.
4
+ package struct SigningCredential: Sendable, Equatable {
5
+ package let keyId: String
6
+ package let secret: String
7
+
8
+ package init(keyId: String, secret: String) {
9
+ self.keyId = keyId
10
+ self.secret = secret
11
+ }
12
+ }
13
+
14
+ /// What reading the credential found. `unavailable` is not `missing`: before the first unlock
15
+ /// after a reboot iOS can relaunch the app (for a region event) while the Keychain can't be read
16
+ /// yet. Taking that for "not registered" would register again and replace a working credential.
17
+ package enum CredentialRead: Sendable, Equatable {
18
+ case present(SigningCredential)
19
+ case missing
20
+ case unavailable
21
+ }
22
+
23
+ /// Where the install's signing credential lives: the Keychain on a device
24
+ /// (AfterFirstUnlockThisDeviceOnly), memory in tests.
25
+ package protocol CredentialStore: Sendable {
26
+ /// The key id and secret, read together.
27
+ func read() -> CredentialRead
28
+
29
+ /// Keep a new credential, replacing any old one. Throws when it couldn't be kept (the Keychain
30
+ /// refused it): the registration it came from then doesn't count.
31
+ func save(keyId: String, secret: String) throws
32
+
33
+ /// Forget the credential (deleteMyData, or an earlier installation's). Throws when it couldn't
34
+ /// be forgotten (the Keychain refused), so nothing claims the device's data is gone when it isn't.
35
+ func clear() throws
36
+ }
37
+
38
+ /// A credential kept only while this install's id is. On iOS the Keychain outlives the app and its
39
+ /// files don't: after a reinstall the old install's credential would sign for a new install id.
40
+ /// A credential with no install id beside it (none saved, not merely unreadable) belongs to an
41
+ /// earlier installation, so it's forgotten and this install registers as itself, as on Android,
42
+ /// where uninstalling wipes the Keystore. Checked once per process: from then on a credential is
43
+ /// only saved by a registration, which makes the install id first.
44
+ package final class InstallBoundCredentialStore: CredentialStore, @unchecked Sendable {
45
+ private let base: any CredentialStore
46
+ private let installId: any ValueStore<String>
47
+ private let lock = NSLock()
48
+ private var checked = false
49
+
50
+ package init(_ base: any CredentialStore, installId: any ValueStore<String>) {
51
+ self.base = base
52
+ self.installId = installId
53
+ }
54
+
55
+ package func read() -> CredentialRead {
56
+ lock.sync {
57
+ let read = base.read()
58
+ guard !checked, case .present = read else {
59
+ if read == .missing { checked = true }
60
+ return read
61
+ }
62
+ let id: String?
63
+ do {
64
+ id = try installId.load()
65
+ } catch {
66
+ return .unavailable
67
+ }
68
+ if let id, !id.isEmpty {
69
+ checked = true
70
+ return read
71
+ }
72
+ do {
73
+ try base.clear()
74
+ } catch {
75
+ // Still there: not this install's, and not gone either. Asked again next time.
76
+ return .unavailable
77
+ }
78
+ checked = true
79
+ BubblLog.info("A credential left by an earlier installation of the app was forgotten")
80
+ return .missing
81
+ }
82
+ }
83
+
84
+ package func save(keyId: String, secret: String) throws {
85
+ try base.save(keyId: keyId, secret: secret)
86
+ }
87
+
88
+ package func clear() throws {
89
+ try base.clear()
90
+ }
91
+ }
92
+
93
+ /// A CredentialStore in memory: for tests, and for nothing else.
94
+ package final class InMemoryCredentialStore: CredentialStore, @unchecked Sendable {
95
+ private let lock = NSLock()
96
+ private var credential: SigningCredential?
97
+
98
+ package init() {}
99
+
100
+ /// The current key id, for tests.
101
+ package func keyId() -> String? {
102
+ lock.sync { credential?.keyId }
103
+ }
104
+
105
+ package func read() -> CredentialRead {
106
+ lock.sync { credential.map(CredentialRead.present) ?? .missing }
107
+ }
108
+
109
+ package func save(keyId: String, secret: String) throws {
110
+ lock.sync { credential = SigningCredential(keyId: keyId, secret: secret) }
111
+ }
112
+
113
+ package func clear() throws {
114
+ lock.sync { credential = nil }
115
+ }
116
+ }
@@ -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.1"
5
+ }