@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,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
+ }
@@ -0,0 +1,160 @@
1
+ import Foundation
2
+
3
+ package enum SyncResult: Sendable, Equatable {
4
+ /// The server already has all of it.
5
+ case upToDate
6
+ /// Sent; `fields` are the ones that had changed.
7
+ case sent(fields: Set<String>)
8
+ case failed(ApiFailure)
9
+ }
10
+
11
+ /// Keeps the server's copy of the device (PUT /device) in step with the device itself: the OS and
12
+ /// app version, locale, the push token, permissions, consent. Only fields that changed since the
13
+ /// server last acknowledged them are sent (the contract leaves anything left out unchanged), so an
14
+ /// app open where nothing changed costs no request at all.
15
+ package final class DeviceSync: Sendable {
16
+ private let api: DeviceApiClient
17
+ /// What the server last acknowledged, as sent.
18
+ private let acknowledged: any ValueStore<[String: JSONValue]>
19
+ private let lock = AsyncMutex()
20
+
21
+ package init(api: DeviceApiClient, acknowledged: any ValueStore<[String: JSONValue]>) {
22
+ self.api = api
23
+ self.acknowledged = acknowledged
24
+ }
25
+
26
+ /// Send whatever in `attributes` the server doesn't have yet; everything when `force`.
27
+ package func sync(_ attributes: [String: JSONValue], force: Bool = false) async -> SyncResult {
28
+ await lock.withLock { await self.syncLocked(attributes, force: force) }
29
+ }
30
+
31
+ private func syncLocked(_ attributes: [String: JSONValue], force: Bool) async -> SyncResult {
32
+ let known: [String: JSONValue]?
33
+ do {
34
+ known = force ? nil : try acknowledged.load()
35
+ } catch {
36
+ return .failed(.backoff)
37
+ }
38
+ // JSONValue compares objects by content, so key order is never a change.
39
+ let changes = attributes.filter { key, value in known?[key] != value || known?.keys.contains(key) != true }
40
+ if changes.isEmpty { return .upToDate }
41
+
42
+ let response: ApiResponse
43
+ do {
44
+ let body = String(decoding: try JSONEncoder().encode(changes), as: UTF8.self)
45
+ response = try await api.request("PUT", "api/v1/device", body: body)
46
+ } catch {
47
+ return .failed(.backoff)
48
+ }
49
+ guard response.isSuccessful else { return .failed(ApiFailure.of(response)) }
50
+
51
+ var merged = known ?? [:]
52
+ merged.merge(changes) { _, new in new }
53
+ try? acknowledged.save(merged)
54
+ return .sent(fields: Set(changes.keys))
55
+ }
56
+
57
+ /// POST /installs has just sent `attributes` in full: the server has exactly those now.
58
+ package func registered(_ attributes: [String: JSONValue]) {
59
+ try? acknowledged.save(attributes)
60
+ }
61
+
62
+ /// Whether the server has ever acknowledged this device.
63
+ package var known: Bool { (try? acknowledged.load()) != nil }
64
+
65
+ /// The server lost track of the device (or it was erased): send everything next time.
66
+ package func forget() {
67
+ try? acknowledged.delete()
68
+ }
69
+
70
+ /// The server dropped `fields` (a push token Apple called dead): send them again next time,
71
+ /// and only them.
72
+ package func forget(_ fields: Set<String>) async {
73
+ await lock.withLock {
74
+ guard var known = try? self.acknowledged.load() else { return }
75
+ for field in fields { known.removeValue(forKey: field) }
76
+ try? self.acknowledged.save(known)
77
+ }
78
+ }
79
+ }
80
+
81
+ /// The device's segments as the app last set them, and whether the server has them yet
82
+ /// (PUT /device/segments replaces them whole). Registration sends them too.
83
+ package final class Segments: Sendable {
84
+ package static let maxSegments = 100
85
+ package static let maxLength = 255
86
+
87
+ package struct Saved: Codable, Sendable, Equatable {
88
+ package var segments: [String]
89
+ package var pending: Bool
90
+ }
91
+
92
+ private let store: any ValueStore<Saved>
93
+ private let lock = NSLock()
94
+ private let cache = Cached<Saved>()
95
+
96
+ package init(store: any ValueStore<Saved>) {
97
+ self.store = store
98
+ }
99
+
100
+ /// The segments the app set; nil if it never has.
101
+ package var current: [String]? { lock.sync { (try? load())??.segments } }
102
+
103
+ package var pending: Bool { lock.sync { (try? load())??.pending == true } }
104
+
105
+ /// Forget them (deleteMyData): the device starts again with none.
106
+ package func forget() throws {
107
+ try lock.sync {
108
+ try store.delete()
109
+ cache.set(nil)
110
+ }
111
+ }
112
+
113
+ private func load() throws -> Saved? {
114
+ try cache.value { try store.load() }
115
+ }
116
+
117
+ private func save(_ saved: Saved) throws {
118
+ try store.save(saved)
119
+ cache.set(saved)
120
+ }
121
+
122
+ /// Keeps `segments` (trimmed, de-duplicated, at most 100 of 255 characters); false if unchanged.
123
+ @discardableResult
124
+ package func set(_ segments: [String]) throws -> Bool {
125
+ var seen = Set<String>()
126
+ let clean = segments
127
+ .map { String($0.trimmingCharacters(in: .whitespacesAndNewlines).prefix(Self.maxLength)) }
128
+ .filter { !$0.isEmpty && seen.insert($0).inserted }
129
+ .prefix(Self.maxSegments)
130
+ return try lock.sync {
131
+ let saved = try load()
132
+ if saved?.segments == Array(clean), saved?.pending == false { return false }
133
+ try save(Saved(segments: Array(clean), pending: true))
134
+ return true
135
+ }
136
+ }
137
+
138
+ /// The server has `segments` now (unless the app changed them again meanwhile).
139
+ package func sent(_ segments: [String]) {
140
+ lock.sync {
141
+ guard (try? load())??.segments == segments else { return }
142
+ try? save(Saved(segments: segments, pending: false))
143
+ }
144
+ }
145
+
146
+ /// PUT /device/segments with the pending segments, if any.
147
+ package func push(_ api: DeviceApiClient) async -> SyncResult {
148
+ guard pending, let segments = current else { return .upToDate }
149
+ let response: ApiResponse
150
+ do {
151
+ let body = String(decoding: try JSONEncoder().encode(["segments": segments]), as: UTF8.self)
152
+ response = try await api.request("PUT", "api/v1/device/segments", body: body)
153
+ } catch {
154
+ return .failed(.backoff)
155
+ }
156
+ guard response.isSuccessful else { return .failed(ApiFailure.of(response)) }
157
+ sent(segments)
158
+ return .sent(fields: ["segments"])
159
+ }
160
+ }
@@ -0,0 +1,24 @@
1
+ import Foundation
2
+
3
+ /// What a build's provisioning profile (embedded.mobileprovision) says about push: which APNs
4
+ /// environment its device tokens belong to. A development-signed build's tokens are sandbox
5
+ /// tokens; ad hoc, TestFlight and App Store builds' are production ones. Sent with the token
6
+ /// (PUT /device apns_environment), since a token sent to the wrong environment is refused
7
+ /// (BadDeviceToken).
8
+ package enum ProvisioningProfile {
9
+ /// "sandbox" or "production" from the profile's aps-environment entitlement; nil when the data
10
+ /// isn't a profile or the app has no push entitlement.
11
+ ///
12
+ /// A profile is a CMS-signed file with its property list in plain text inside, so the plist is
13
+ /// cut out between "<?xml" and "</plist>" rather than verifying the signature (iOS did that
14
+ /// when it installed the app).
15
+ package static func apnsEnvironment(_ profile: Data) -> String? {
16
+ guard let start = profile.range(of: Data("<?xml".utf8)),
17
+ let end = profile.range(of: Data("</plist>".utf8), in: start.lowerBound..<profile.endIndex),
18
+ let plist = try? PropertyListSerialization.propertyList(from: profile.subdata(in: start.lowerBound..<end.upperBound), format: nil),
19
+ let entitlements = (plist as? [String: Any])?["Entitlements"] as? [String: Any],
20
+ let environment = entitlements["aps-environment"] as? String
21
+ else { return nil }
22
+ return environment == "development" ? "sandbox" : "production"
23
+ }
24
+ }