@bubblsdk/react-native-sdk 4.1.7 → 5.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BubblReactNativeSdk.podspec +29 -9
- package/CHANGELOG.md +52 -1
- package/LICENSE +202 -1
- package/README.md +172 -83
- package/android/build.gradle +33 -30
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
- package/app.plugin.js +2 -0
- package/ios/BubblPush.swift +44 -0
- package/ios/BubblSdk.h +7 -0
- package/ios/BubblSdk.mm +252 -0
- package/ios/BubblSdkImpl.swift +335 -0
- package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
- package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
- package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
- package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
- package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
- package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
- package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
- package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
- package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
- package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
- package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-sdk/BubblCore/API/Http.swift +39 -0
- package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
- package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
- package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
- package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
- package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
- package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
- package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
- package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
- package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
- package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
- package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
- package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
- package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
- package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
- package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
- package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
- package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
- package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
- package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
- package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
- package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
- package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
- package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
- package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
- package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
- package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
- package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
- package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
- package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
- package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
- package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
- package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
- package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
- package/jest/index.js +75 -0
- package/lib/commonjs/BubblModule.js +14 -0
- package/lib/commonjs/BubblModule.js.map +1 -0
- package/lib/commonjs/BubblModule.web.js +13 -0
- package/lib/commonjs/BubblModule.web.js.map +1 -0
- package/lib/commonjs/NativeBubblSdk.js +20 -0
- package/lib/commonjs/NativeBubblSdk.js.map +1 -0
- package/lib/commonjs/index.js +336 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/BubblModule.js +5 -0
- package/lib/module/BubblModule.js.map +1 -0
- package/lib/module/BubblModule.web.js +9 -0
- package/lib/module/BubblModule.web.js.map +1 -0
- package/lib/module/NativeBubblSdk.js +18 -0
- package/lib/module/NativeBubblSdk.js.map +1 -0
- package/lib/module/index.js +331 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +111 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/types.d.ts +192 -0
- package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/BubblModule.d.ts +3 -0
- package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +111 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/types.d.ts +192 -0
- package/lib/typescript/module/src/types.d.ts.map +1 -0
- package/package.json +105 -24
- package/plugin/build/imageNotifications.d.ts +35 -0
- package/plugin/build/imageNotifications.js +198 -0
- package/plugin/build/index.d.ts +84 -0
- package/plugin/build/index.js +176 -0
- package/src/BubblModule.ts +2 -0
- package/src/BubblModule.web.ts +9 -0
- package/src/NativeBubblSdk.ts +67 -0
- package/src/index.ts +338 -282
- package/src/types.ts +182 -0
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
- package/ios/BubblSdk.swift +0 -802
- package/ios/BubblSdkBridge.m +0 -86
- package/react-native.config.js +0 -11
- package/src/specs/NativeBubblSdk.ts +0 -54
|
@@ -0,0 +1,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
|
+
}
|