@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,523 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// What the app started the engine with, kept so the engine can start itself when the OS wakes the
|
|
4
|
+
/// app (a geofence, a push) before the app's own code has run.
|
|
5
|
+
package struct EngineConfig: Codable, Sendable, Equatable {
|
|
6
|
+
/// Nil for a device started with a credential issued outside the app.
|
|
7
|
+
package let apiKey: String?
|
|
8
|
+
package let baseUrl: String
|
|
9
|
+
|
|
10
|
+
package init(apiKey: String?, baseUrl: String) {
|
|
11
|
+
self.apiKey = apiKey
|
|
12
|
+
self.baseUrl = baseUrl
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// A device credential issued outside the app (installs provisioned ahead of time, an app's own
|
|
17
|
+
/// pairing flow), which a device can be started with instead of an API key.
|
|
18
|
+
package struct IssuedCredential: Sendable, Equatable {
|
|
19
|
+
package let keyId: String
|
|
20
|
+
package let secret: String
|
|
21
|
+
/// The install the credential was issued for.
|
|
22
|
+
package let installId: String
|
|
23
|
+
|
|
24
|
+
package init(keyId: String, secret: String, installId: String) {
|
|
25
|
+
self.keyId = keyId
|
|
26
|
+
self.secret = secret
|
|
27
|
+
self.installId = installId
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// What starting Bubbl means for the device's identity (Android's EngineStart). A device is either
|
|
32
|
+
/// an install that registers itself with an API key, or one started with a credential issued
|
|
33
|
+
/// outside the app. Which one, and which credential, is kept (it outlives `stop`), so the next
|
|
34
|
+
/// start knows whether it's the same device.
|
|
35
|
+
package enum EngineStart {
|
|
36
|
+
package static let apiKey = "api_key"
|
|
37
|
+
|
|
38
|
+
/// With an API key, the key itself: a different key is another workspace (Sandbox and
|
|
39
|
+
/// Production have their own), where this device has to register as new. The address isn't
|
|
40
|
+
/// part of it: the same key at another address is the same workspace.
|
|
41
|
+
package static func identity(_ config: EngineConfig, _ credential: IssuedCredential?) -> String {
|
|
42
|
+
guard let credential else { return config.apiKey.map { "\(apiKey) \($0)" } ?? apiKey }
|
|
43
|
+
return "credential \(credential.keyId) \(credential.installId) \(config.baseUrl)"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Whether starting as `next` makes this a new device, so what's kept is wiped first (consent
|
|
47
|
+
/// stays): from an API key to a credential or back, from one credential to another, or from
|
|
48
|
+
/// one API key to another (an app update going live from Sandbox to Production, or back; the
|
|
49
|
+
/// old credential and install id belong to the other workspace). Not on the first start
|
|
50
|
+
/// (nothing to wipe), nor when it's the same again, nor from an API key an older SDK kept
|
|
51
|
+
/// without saying which (it can't tell).
|
|
52
|
+
package static func startsAfresh(previous: String?, next: String) -> Bool {
|
|
53
|
+
guard let previous, previous != next else { return false }
|
|
54
|
+
return !(previous == apiKey && next.hasPrefix("\(apiKey) "))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// This install's push token, as PUT /device takes it.
|
|
59
|
+
package struct PushToken: Sendable, Equatable {
|
|
60
|
+
package let token: String
|
|
61
|
+
/// "apns" on iOS.
|
|
62
|
+
package let type: String
|
|
63
|
+
/// "production" or "sandbox" for APNs.
|
|
64
|
+
package let environment: String?
|
|
65
|
+
|
|
66
|
+
package init(token: String, type: String, environment: String?) {
|
|
67
|
+
self.token = token
|
|
68
|
+
self.type = type
|
|
69
|
+
self.environment = environment
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// What only the platform knows about the device.
|
|
74
|
+
package protocol DevicePlatform: Sendable {
|
|
75
|
+
/// The contract's DeviceAttributes the device knows by itself: platform, os_version,
|
|
76
|
+
/// device_model, app_id, app_version, locale, country, timezone.
|
|
77
|
+
func attributes() -> [String: JSONValue]
|
|
78
|
+
/// The permissions object PUT /device takes; nil until the platform can say.
|
|
79
|
+
func permissions() -> JSONValue?
|
|
80
|
+
func pushToken() -> PushToken?
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Where each piece of the engine's state is kept: files on a device, memory in tests.
|
|
84
|
+
package struct EngineStores: Sendable {
|
|
85
|
+
package let credentials: any CredentialStore
|
|
86
|
+
package let events: any EventStore
|
|
87
|
+
package let geofenceState: any GeofenceStateStore
|
|
88
|
+
package let config: any ValueStore<ConfigSync.Saved>
|
|
89
|
+
package let deviceAcknowledged: any ValueStore<[String: JSONValue]>
|
|
90
|
+
package let segments: any ValueStore<Segments.Saved>
|
|
91
|
+
package let privacy: any ValueStore<PrivacyState>
|
|
92
|
+
package let recentNotifications: any ValueStore<[String]>
|
|
93
|
+
package let installId: any ValueStore<String>
|
|
94
|
+
package let clockOffset: any ValueStore<Int64>
|
|
95
|
+
/// Unix seconds (device clock) until which the server asked not to be called.
|
|
96
|
+
package let pausedUntil: any ValueStore<Int64>
|
|
97
|
+
/// Geofence transitions not yet sent, so one survives the app being suspended or killed.
|
|
98
|
+
package let transitions: any ValueStore<[Transition]>
|
|
99
|
+
/// Region events that came before the first unlock after a reboot, when nothing could be read.
|
|
100
|
+
package let lockedDrops: any ValueStore<Int>
|
|
101
|
+
|
|
102
|
+
package init(
|
|
103
|
+
credentials: any CredentialStore, events: any EventStore, geofenceState: any GeofenceStateStore,
|
|
104
|
+
config: any ValueStore<ConfigSync.Saved>, deviceAcknowledged: any ValueStore<[String: JSONValue]>,
|
|
105
|
+
segments: any ValueStore<Segments.Saved>, privacy: any ValueStore<PrivacyState>,
|
|
106
|
+
recentNotifications: any ValueStore<[String]>, installId: any ValueStore<String>,
|
|
107
|
+
clockOffset: any ValueStore<Int64>, pausedUntil: any ValueStore<Int64>,
|
|
108
|
+
transitions: any ValueStore<[Transition]>, lockedDrops: any ValueStore<Int>
|
|
109
|
+
) {
|
|
110
|
+
self.credentials = credentials
|
|
111
|
+
self.events = events
|
|
112
|
+
self.geofenceState = geofenceState
|
|
113
|
+
self.config = config
|
|
114
|
+
self.deviceAcknowledged = deviceAcknowledged
|
|
115
|
+
self.segments = segments
|
|
116
|
+
self.privacy = privacy
|
|
117
|
+
self.recentNotifications = recentNotifications
|
|
118
|
+
self.installId = installId
|
|
119
|
+
self.clockOffset = clockOffset
|
|
120
|
+
self.pausedUntil = pausedUntil
|
|
121
|
+
self.transitions = transitions
|
|
122
|
+
self.lockedDrops = lockedDrops
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Everything in memory: for tests.
|
|
126
|
+
package static func inMemory(credentials: any CredentialStore = InMemoryCredentialStore()) -> EngineStores {
|
|
127
|
+
EngineStores(
|
|
128
|
+
credentials: credentials, events: InMemoryEventStore(), geofenceState: InMemoryGeofenceStateStore(),
|
|
129
|
+
config: InMemoryValueStore(), deviceAcknowledged: InMemoryValueStore(), segments: InMemoryValueStore(),
|
|
130
|
+
privacy: InMemoryValueStore(), recentNotifications: InMemoryValueStore(), installId: InMemoryValueStore(),
|
|
131
|
+
clockOffset: InMemoryValueStore(), pausedUntil: InMemoryValueStore(),
|
|
132
|
+
transitions: InMemoryValueStore(), lockedDrops: InMemoryValueStore()
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// One file each in `directory`, written with `writeOptions` (on iOS, the file protection class).
|
|
137
|
+
/// The credential counts only alongside the install id it was registered with.
|
|
138
|
+
package static func files(in directory: URL, writeOptions: Data.WritingOptions, credentials: any CredentialStore) -> EngineStores {
|
|
139
|
+
func url(_ name: String) -> URL { directory.appendingPathComponent(name) }
|
|
140
|
+
let installId = FileValueStore<String>(url: url("install_id.json"), writeOptions: writeOptions)
|
|
141
|
+
return EngineStores(
|
|
142
|
+
credentials: InstallBoundCredentialStore(credentials, installId: installId),
|
|
143
|
+
events: FileEventStore(url: url("events.json"), writeOptions: writeOptions),
|
|
144
|
+
geofenceState: FileGeofenceStateStore(url: url("geofences.json"), writeOptions: writeOptions),
|
|
145
|
+
config: FileValueStore(url: url("config.json"), writeOptions: writeOptions),
|
|
146
|
+
deviceAcknowledged: FileValueStore(url: url("device.json"), writeOptions: writeOptions),
|
|
147
|
+
segments: FileValueStore(url: url("segments.json"), writeOptions: writeOptions),
|
|
148
|
+
privacy: FileValueStore(url: url("privacy.json"), writeOptions: writeOptions),
|
|
149
|
+
recentNotifications: FileValueStore(url: url("recent_notifications.json"), writeOptions: writeOptions),
|
|
150
|
+
installId: installId,
|
|
151
|
+
clockOffset: FileValueStore(url: url("clock_offset.json"), writeOptions: writeOptions),
|
|
152
|
+
pausedUntil: FileValueStore(url: url("paused_until.json"), writeOptions: writeOptions),
|
|
153
|
+
transitions: FileValueStore(url: url("transitions.json"), writeOptions: writeOptions),
|
|
154
|
+
lockedDrops: FileValueStore(url: url("locked_drops.json"), writeOptions: writeOptions)
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// What a piece of background work asks of whoever runs it.
|
|
160
|
+
package enum WorkOutcome: Sendable, Equatable {
|
|
161
|
+
case done
|
|
162
|
+
/// Not done yet: run it again after at least this long.
|
|
163
|
+
case retry(afterSeconds: Int)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// The engine, free of iOS: its parts wired together from the saved EngineConfig, what it may do
|
|
167
|
+
/// now (consent, a server pause, the minimum SDK version), and each piece of work it runs, as
|
|
168
|
+
/// Android's EngineGraph and EngineWork. The iOS layer supplies the platform (Keychain, URLSession,
|
|
169
|
+
/// files, device details, region monitoring) and runs the work: now, on retry, and in the
|
|
170
|
+
/// background.
|
|
171
|
+
package final class EngineCore: Sendable {
|
|
172
|
+
package static let maxProperties = 50
|
|
173
|
+
|
|
174
|
+
/// A custom event's name: 1–100 of A–Z a–z 0–9 _ . : -.
|
|
175
|
+
package static func isEventName(_ name: String) -> Bool {
|
|
176
|
+
(1...100).contains(name.unicodeScalars.count) && name.unicodeScalars.allSatisfy {
|
|
177
|
+
$0.isASCII && (CharacterSet.alphanumerics.contains($0) || "_.:-".unicodeScalars.contains($0))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
package let config: EngineConfig
|
|
182
|
+
package let clock: ServerClock
|
|
183
|
+
package let api: DeviceApiClient
|
|
184
|
+
package let configSync: ConfigSync
|
|
185
|
+
package let deviceSync: DeviceSync
|
|
186
|
+
package let segments: Segments
|
|
187
|
+
package let events: EventQueue
|
|
188
|
+
package let geofences: GeofenceEngine
|
|
189
|
+
package let notifications: NotificationSource
|
|
190
|
+
package let recentNotifications: RecentNotifications
|
|
191
|
+
package let privacy: PrivacyStore
|
|
192
|
+
let stores: EngineStores
|
|
193
|
+
private let platform: any DevicePlatform
|
|
194
|
+
let deviceNowSeconds: @Sendable () -> Int64
|
|
195
|
+
let hooks: Hooks
|
|
196
|
+
/// Guards the pending transitions' load-change-save.
|
|
197
|
+
let transitionsLock = NSLock()
|
|
198
|
+
/// One sending of the transitions at a time (a region event's and a retry's), so none is sent twice.
|
|
199
|
+
let sendingTransitions = AsyncMutex()
|
|
200
|
+
/// Region events dropped before the first unlock, not yet added to the saved count.
|
|
201
|
+
let unsavedLockedDrops = Counter()
|
|
202
|
+
/// DELETE /device attempts refused for good, this process (deleteMyData gives up after a few).
|
|
203
|
+
let eraseAttempts = AttemptCounter()
|
|
204
|
+
/// What the log last said about this device in Sandbox (announceSandbox).
|
|
205
|
+
let sandboxAnnounced = LastSaid()
|
|
206
|
+
|
|
207
|
+
final class Counter: @unchecked Sendable {
|
|
208
|
+
private let lock = NSLock()
|
|
209
|
+
private var count = 0
|
|
210
|
+
func add(_ n: Int) { lock.sync { count += n } }
|
|
211
|
+
/// The count, and whether `save` kept it (then it starts again from 0).
|
|
212
|
+
func drain(_ save: (Int) throws -> Void) rethrows {
|
|
213
|
+
try lock.sync {
|
|
214
|
+
guard count > 0 else { return }
|
|
215
|
+
try save(count)
|
|
216
|
+
count = 0
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
var value: Int { lock.sync { count } }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// What the last POST /installs sent about the device, to count as acknowledged once it succeeds.
|
|
223
|
+
final class Hooks: @unchecked Sendable {
|
|
224
|
+
private let lock = NSLock()
|
|
225
|
+
private weak var _core: EngineCore?
|
|
226
|
+
private var _installAttributes: [String: JSONValue]?
|
|
227
|
+
private var _credentialRejected: (@Sendable () -> Void)?
|
|
228
|
+
var credentialRejected: (@Sendable () -> Void)? {
|
|
229
|
+
get { lock.sync { _credentialRejected } }
|
|
230
|
+
set { lock.sync { _credentialRejected = newValue } }
|
|
231
|
+
}
|
|
232
|
+
var core: EngineCore? {
|
|
233
|
+
get { lock.sync { _core } }
|
|
234
|
+
set { lock.sync { _core = newValue } }
|
|
235
|
+
}
|
|
236
|
+
var installAttributes: [String: JSONValue]? {
|
|
237
|
+
get { lock.sync { _installAttributes } }
|
|
238
|
+
set { lock.sync { _installAttributes = newValue } }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
package init(
|
|
243
|
+
config: EngineConfig,
|
|
244
|
+
stores: EngineStores,
|
|
245
|
+
platform: any DevicePlatform,
|
|
246
|
+
http: any HttpClient,
|
|
247
|
+
monitor: any RegionMonitor,
|
|
248
|
+
deviceNowSeconds: @escaping @Sendable () -> Int64 = { Int64(Date().timeIntervalSince1970) }
|
|
249
|
+
) {
|
|
250
|
+
self.config = config
|
|
251
|
+
self.stores = stores
|
|
252
|
+
self.platform = platform
|
|
253
|
+
self.deviceNowSeconds = deviceNowSeconds
|
|
254
|
+
|
|
255
|
+
let offsetStore = stores.clockOffset
|
|
256
|
+
let clock = ServerClock(
|
|
257
|
+
initialOffsetSeconds: (try? offsetStore.load()) ?? 0,
|
|
258
|
+
deviceNowSeconds: deviceNowSeconds,
|
|
259
|
+
onChange: { try? offsetStore.save($0) }
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
let hooks = Hooks()
|
|
263
|
+
let api = DeviceApiClient(
|
|
264
|
+
baseUrl: config.baseUrl,
|
|
265
|
+
apiKey: config.apiKey,
|
|
266
|
+
sdkVersion: BubblVersion.sdk,
|
|
267
|
+
http: http,
|
|
268
|
+
credentials: stores.credentials,
|
|
269
|
+
clock: clock,
|
|
270
|
+
installBody: {
|
|
271
|
+
guard let core = hooks.core else { throw DeviceApiError.credentialsLocked }
|
|
272
|
+
return try core.installBody()
|
|
273
|
+
},
|
|
274
|
+
onRegistered: { data in hooks.core?.registered(data) },
|
|
275
|
+
onCredentialRejected: { hooks.credentialRejected?() }
|
|
276
|
+
)
|
|
277
|
+
let configSync = ConfigSync(api: api, clock: clock, store: stores.config)
|
|
278
|
+
|
|
279
|
+
self.clock = clock
|
|
280
|
+
self.api = api
|
|
281
|
+
self.configSync = configSync
|
|
282
|
+
self.hooks = hooks
|
|
283
|
+
deviceSync = DeviceSync(api: api, acknowledged: stores.deviceAcknowledged)
|
|
284
|
+
segments = Segments(store: stores.segments)
|
|
285
|
+
events = EventQueue(store: stores.events, api: api, clock: clock, batchSize: { configSync.current?.maxEventsPerRequest ?? EventQueue.batchSize })
|
|
286
|
+
geofences = GeofenceEngine(api: api, clock: clock, store: stores.geofenceState, monitor: monitor)
|
|
287
|
+
notifications = NotificationSource(api: api)
|
|
288
|
+
recentNotifications = RecentNotifications(store: stores.recentNotifications)
|
|
289
|
+
privacy = PrivacyStore(store: stores.privacy)
|
|
290
|
+
hooks.core = self
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// MARK: - What the engine may do
|
|
294
|
+
|
|
295
|
+
/// The server asked for a pause (workspace_paused, own_app_not_in_plan) that hasn't run out.
|
|
296
|
+
package var isPaused: Bool { deviceNowSeconds() < pausedUntil }
|
|
297
|
+
|
|
298
|
+
package var pausedUntilSeconds: Int64? { isPaused ? pausedUntil : nil }
|
|
299
|
+
|
|
300
|
+
private var pausedUntil: Int64 {
|
|
301
|
+
let saved: Int64?? = try? stores.pausedUntil.load()
|
|
302
|
+
return (saved ?? nil) ?? 0
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/// This SDK is at least the workspace's minimum version (GET /config); an older one stops.
|
|
306
|
+
package var sdkSupported: Bool { configSync.current?.supports(BubblVersion.sdk) != false }
|
|
307
|
+
|
|
308
|
+
/// The engine may call the API and show notifications: consent allows it, not paused, supported.
|
|
309
|
+
package var isActive: Bool { privacy.state?.active == true && !isPaused && sdkSupported }
|
|
310
|
+
|
|
311
|
+
/// …and may use location as well.
|
|
312
|
+
package var locationActive: Bool { isActive && privacy.state?.locationActive == true }
|
|
313
|
+
|
|
314
|
+
/// Whether this install has ever registered (so there may be a device on the server).
|
|
315
|
+
package var everRegistered: Bool {
|
|
316
|
+
if case .present = stores.credentials.read() { return true }
|
|
317
|
+
return deviceSync.known
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
package func pause(hours: Int, code: String?) {
|
|
321
|
+
try? stores.pausedUntil.save(deviceNowSeconds() + Int64(hours) * 3_600)
|
|
322
|
+
BubblLog.warning("Bubbl paused for \(hours)h (\(code ?? "no code"))")
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// MARK: - The device
|
|
326
|
+
|
|
327
|
+
/// This installation's id, made on first use and kept until the user erases their data (then
|
|
328
|
+
/// the next one is a new device). Throws while it can't be read.
|
|
329
|
+
package func installId() throws -> String {
|
|
330
|
+
if let id = try stores.installId.load(), !id.isEmpty { return id }
|
|
331
|
+
let id = UUID().uuidString.lowercased()
|
|
332
|
+
try stores.installId.save(id)
|
|
333
|
+
return id
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/// What the device says about itself now, for POST /installs and PUT /device.
|
|
337
|
+
package func deviceAttributes() -> [String: JSONValue] {
|
|
338
|
+
var attributes = platform.attributes()
|
|
339
|
+
attributes["sdk_version"] = .string(BubblVersion.sdk)
|
|
340
|
+
if let token = platform.pushToken() {
|
|
341
|
+
attributes["push_token"] = .string(token.token)
|
|
342
|
+
attributes["push_token_type"] = .string(token.type)
|
|
343
|
+
if let environment = token.environment { attributes["apns_environment"] = .string(environment) }
|
|
344
|
+
}
|
|
345
|
+
if let permissions = platform.permissions() { attributes["permissions"] = permissions }
|
|
346
|
+
if let consent = privacy.state?.consent { attributes["consent"] = .bool(consent) }
|
|
347
|
+
return attributes
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private func installBody() throws -> [String: Any] {
|
|
351
|
+
let id = try installId()
|
|
352
|
+
let attributes = deviceAttributes()
|
|
353
|
+
hooks.installAttributes = attributes
|
|
354
|
+
var body = JSON.any(.object(attributes)) as? [String: Any] ?? [:]
|
|
355
|
+
body["install_id"] = id
|
|
356
|
+
if let segments = segments.current { body["segments"] = segments }
|
|
357
|
+
return body
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private func registered(_ data: [String: Any]) {
|
|
361
|
+
if let attributes = hooks.installAttributes { deviceSync.registered(attributes) }
|
|
362
|
+
if let current = segments.current { segments.sent(current) }
|
|
363
|
+
if let config = data["config"].flatMap(JSON.value) { try? configSync.registered(config) }
|
|
364
|
+
announceSandbox()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// MARK: - Work
|
|
368
|
+
|
|
369
|
+
/// PUT /device with whatever changed (`force`: everything).
|
|
370
|
+
package func syncDevice(force: Bool = false) async -> WorkOutcome {
|
|
371
|
+
guard isActive else { return .done }
|
|
372
|
+
if case .failed(let failure) = await deviceSync.sync(deviceAttributes(), force: force) {
|
|
373
|
+
return await handle(failure, "Syncing the device")
|
|
374
|
+
}
|
|
375
|
+
return .done
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/// Send the event queue.
|
|
379
|
+
package func flushEvents() async -> WorkOutcome {
|
|
380
|
+
guard isActive else { return .done }
|
|
381
|
+
if case .failed(let failure) = await events.flush() {
|
|
382
|
+
return await handle(failure, "Sending events")
|
|
383
|
+
}
|
|
384
|
+
return .done
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/// PUT /device/segments, if the app changed them since.
|
|
388
|
+
package func pushSegments() async -> WorkOutcome {
|
|
389
|
+
guard isActive else { return .done }
|
|
390
|
+
if case .failed(let failure) = await segments.push(api) {
|
|
391
|
+
return await handle(failure, "Setting segments")
|
|
392
|
+
}
|
|
393
|
+
return .done
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/// Every hour or so, and in the background: the config, then the device, events and segments.
|
|
397
|
+
/// An SDK below the workspace's minimum does nothing but ask for the config, so it starts again
|
|
398
|
+
/// when the minimum is lowered (without an app update).
|
|
399
|
+
/// How fresh the config must be when the app comes to the front (at most one GET /config a
|
|
400
|
+
/// minute, a 304 when unchanged): a change made in the dashboard, such as approving a Sandbox
|
|
401
|
+
/// test device, is seen on the next open rather than at the hourly refresh.
|
|
402
|
+
package static let openConfigMaxAgeSeconds: Int64 = 60
|
|
403
|
+
|
|
404
|
+
package func maintenance(configMaxAgeSeconds: Int64? = nil) async -> WorkOutcome {
|
|
405
|
+
guard privacy.state?.active == true, !isPaused else { return .done }
|
|
406
|
+
let wasSupported = sdkSupported
|
|
407
|
+
let refreshed = await configSync.refresh(maxAgeSeconds: configMaxAgeSeconds)
|
|
408
|
+
if case .failed(let failure) = refreshed {
|
|
409
|
+
_ = await handle(failure, "Fetching the config")
|
|
410
|
+
}
|
|
411
|
+
announceSandbox()
|
|
412
|
+
guard sdkSupported else {
|
|
413
|
+
if wasSupported { BubblLog.error("This SDK (\(BubblVersion.sdk)) is older than the workspace's minimum: update it to use Bubbl") }
|
|
414
|
+
return .done
|
|
415
|
+
}
|
|
416
|
+
// Only on the server's answer just now: a saved config that still says "dropped" after the
|
|
417
|
+
// token went again would otherwise send it at every maintenance.
|
|
418
|
+
if refreshed == .updated || refreshed == .unchanged { await resendDroppedPushToken() }
|
|
419
|
+
_ = await syncDevice()
|
|
420
|
+
_ = await flushEvents()
|
|
421
|
+
_ = await pushSegments()
|
|
422
|
+
return .done
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/// The push fields PUT /device sends, forgotten together when the server dropped the token.
|
|
426
|
+
package static let pushTokenFields: Set<String> = ["push_token", "push_token_type", "apns_environment"]
|
|
427
|
+
|
|
428
|
+
/// GET /config says the server has no push token for this device while the app holds one: it
|
|
429
|
+
/// was dropped (Apple or FCM called it dead, or it came with the wrong APNs environment), so the
|
|
430
|
+
/// next PUT /device sends it again.
|
|
431
|
+
func resendDroppedPushToken() async {
|
|
432
|
+
guard configSync.current?.pushTokenRegistered == false, platform.pushToken() != nil else { return }
|
|
433
|
+
BubblLog.info("The server has no push token for this device: sending it again")
|
|
434
|
+
await deviceSync.forget(Self.pushTokenFields)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/// The app came to the front: app.opened, and (with `pull`) the pushes the OS may have
|
|
438
|
+
/// dropped. Returns those notifications, for the app to show. Pulling claims them on the
|
|
439
|
+
/// server, so only a caller that will show them pulls.
|
|
440
|
+
package func appOpened(pull: Bool = true) async -> [JSONValue] {
|
|
441
|
+
guard isActive else { return [] }
|
|
442
|
+
let platform = platform.attributes()
|
|
443
|
+
var data: [String: JSONValue] = ["sdk_version": .string(BubblVersion.sdk)]
|
|
444
|
+
data["app_version"] = platform["app_version"] ?? .null
|
|
445
|
+
data["os_version"] = platform["os_version"] ?? .null
|
|
446
|
+
_ = try? await events.enqueue("app.opened", data: data)
|
|
447
|
+
|
|
448
|
+
guard pull, case .found(let pulled) = await notifications.pull() else { return [] }
|
|
449
|
+
// Each recorded as received and returned to show only the first time it arrives (a push
|
|
450
|
+
// may have brought it already).
|
|
451
|
+
var toShow: [JSONValue] = []
|
|
452
|
+
for notification in pulled {
|
|
453
|
+
if case .show(let json, _) = await notificationArrived(notification, opened: false) { toShow.append(json) }
|
|
454
|
+
}
|
|
455
|
+
return toShow
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/// An event of the app's own (Bubbl.track): `name` of letters, digits and . _ : - (at most
|
|
459
|
+
/// 100), up to 50 flat properties. False, and a warning, when it can't be recorded.
|
|
460
|
+
package func track(_ name: String, properties: [String: JSONValue]) async -> Bool {
|
|
461
|
+
guard isActive else { return false }
|
|
462
|
+
guard Self.isEventName(name) else {
|
|
463
|
+
BubblLog.warning("Bubbl.track: that isn't a valid event name (letters, digits and . _ : -, at most 100)")
|
|
464
|
+
return false
|
|
465
|
+
}
|
|
466
|
+
var flat: [String: JSONValue] = [:]
|
|
467
|
+
for (key, value) in properties.sorted(by: { $0.key < $1.key }).prefix(Self.maxProperties) {
|
|
468
|
+
switch value {
|
|
469
|
+
case .array, .object: BubblLog.warning("Bubbl.track: a property that isn't text, a number or true/false was left out")
|
|
470
|
+
default: flat[key] = value
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
do {
|
|
474
|
+
try await events.enqueue("custom", data: ["name": .string(name), "properties": .object(flat)])
|
|
475
|
+
return true
|
|
476
|
+
} catch {
|
|
477
|
+
return false
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/// What a failure means for the work that met it (Android's EngineWork.result).
|
|
482
|
+
package func handle(_ failure: ApiFailure, _ work: String) async -> WorkOutcome {
|
|
483
|
+
switch failure {
|
|
484
|
+
case .backoff:
|
|
485
|
+
return .retry(afterSeconds: 30)
|
|
486
|
+
case .retryAfter(let seconds):
|
|
487
|
+
return .retry(afterSeconds: seconds)
|
|
488
|
+
case .describeDevice:
|
|
489
|
+
// The server doesn't know the device: tell it everything, then this work tries again.
|
|
490
|
+
deviceSync.forget()
|
|
491
|
+
_ = await syncDevice(force: true)
|
|
492
|
+
return .retry(afterSeconds: 30)
|
|
493
|
+
case .pause(let hours, let code):
|
|
494
|
+
// Nothing is shown in a paused workspace, so there's nothing to retry for.
|
|
495
|
+
if code == "sandbox_pending_full" {
|
|
496
|
+
BubblLog.warning("Bubbl sandbox: the workspace has as many devices waiting for approval as it can take; approve or remove some under Test devices in the dashboard")
|
|
497
|
+
}
|
|
498
|
+
pause(hours: hours, code: code)
|
|
499
|
+
return .done
|
|
500
|
+
case .drop:
|
|
501
|
+
return .done
|
|
502
|
+
case .stopped(let status, let code):
|
|
503
|
+
BubblLog.error("\(work) stopped: HTTP \(status) \(code ?? "")")
|
|
504
|
+
return .done
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
extension JSON {
|
|
510
|
+
/// A JSONValue as JSONSerialization's objects (for APIs that take [String: Any]).
|
|
511
|
+
package static func any(_ value: JSONValue) -> Any? {
|
|
512
|
+
guard let data = try? JSONEncoder().encode(value) else { return nil }
|
|
513
|
+
return try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/// JSONSerialization's objects as a JSONValue, read by JSONDecoder so it's the same everywhere.
|
|
517
|
+
package static func value(_ any: Any) -> JSONValue? {
|
|
518
|
+
guard JSONSerialization.isValidJSONObject(any) || any is String || any is NSNumber,
|
|
519
|
+
let data = try? JSONSerialization.data(withJSONObject: any, options: [.fragmentsAllowed])
|
|
520
|
+
else { return nil }
|
|
521
|
+
return try? JSONDecoder().decode(JSONValue.self, from: data)
|
|
522
|
+
}
|
|
523
|
+
}
|