@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,356 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
#if canImport(UIKit)
|
|
3
|
+
import UIKit
|
|
4
|
+
#endif
|
|
5
|
+
#if !COCOAPODS
|
|
6
|
+
import BubblCore
|
|
7
|
+
import BubblLaunch
|
|
8
|
+
#endif
|
|
9
|
+
|
|
10
|
+
/// Found by name by BubblLaunch's +load (which can't import this module): the end of launch.
|
|
11
|
+
@available(iOS 17, *)
|
|
12
|
+
@objc(BubblLaunchHook)
|
|
13
|
+
final class BubblLaunchHook: NSObject {
|
|
14
|
+
@objc static func applicationDidFinishLaunching() {
|
|
15
|
+
EngineHost.shared.applicationDidFinishLaunching()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// From +load, before main(): iOS wants background task handlers registered before launch ends.
|
|
19
|
+
@objc static func registerBackgroundTasks() {
|
|
20
|
+
#if os(iOS)
|
|
21
|
+
BackgroundRefresh.register()
|
|
22
|
+
#endif
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Holds the engine for the life of the process and runs its work: on start, when the app comes to
|
|
27
|
+
/// the front, and when it goes to the back (with the few seconds iOS gives). Work waits while the
|
|
28
|
+
/// engine's data can't be read (before the first unlock after a reboot).
|
|
29
|
+
@available(iOS 17, *)
|
|
30
|
+
final class EngineHost: @unchecked Sendable {
|
|
31
|
+
static let shared = EngineHost()
|
|
32
|
+
|
|
33
|
+
let runner = WorkRunner(dataReadable: { EngineHost.dataReadable() })
|
|
34
|
+
let platform = AppleDevicePlatform()
|
|
35
|
+
/// Shows notifications: Bubbl's screen in front, a system notification in the background.
|
|
36
|
+
#if os(iOS)
|
|
37
|
+
let presenter: any NotificationPresenting = ScreenPresenter()
|
|
38
|
+
#else
|
|
39
|
+
let presenter: any NotificationPresenting = SystemAlertPresenter()
|
|
40
|
+
#endif
|
|
41
|
+
private let lock = NSLock()
|
|
42
|
+
private var core: EngineCore?
|
|
43
|
+
private var observing = false
|
|
44
|
+
private var listener: (@MainActor @Sendable (BubblMessage) -> Bool)?
|
|
45
|
+
|
|
46
|
+
var current: EngineCore? { lock.sync { core } }
|
|
47
|
+
|
|
48
|
+
/// The app's say over each notification before Bubbl shows it (Bubbl.setNotificationListener).
|
|
49
|
+
var notificationListener: (@MainActor @Sendable (BubblMessage) -> Bool)? {
|
|
50
|
+
get { lock.sync { listener } }
|
|
51
|
+
set { lock.sync { listener = newValue } }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// What `start` was called with, kept so Bubbl can start itself at a later launch.
|
|
55
|
+
struct Started: Codable, Equatable {
|
|
56
|
+
let config: EngineConfig
|
|
57
|
+
let logLevel: BubblOptions.LogLevel
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Starts (or keeps) the engine for `config`, with an API key or a credential issued outside the
|
|
61
|
+
/// app (Android's Bubbl.begin): the same again changes nothing; a different identity (another
|
|
62
|
+
/// credential, or switching between an API key and a credential) wipes what's kept here first
|
|
63
|
+
/// and starts as a new device.
|
|
64
|
+
func start(_ config: EngineConfig, credential: IssuedCredential? = nil, options: BubblOptions) -> EngineCore? {
|
|
65
|
+
SystemLog.use(options.logLevel)
|
|
66
|
+
let identity = EngineStart.identity(config, credential)
|
|
67
|
+
let afresh = EngineStart.startsAfresh(previous: previousIdentity(), next: identity)
|
|
68
|
+
guard let core = engine(for: config, fresh: afresh) else { return nil }
|
|
69
|
+
try? identityStore()?.save(identity)
|
|
70
|
+
try? rejectedStore()?.delete()
|
|
71
|
+
try? startedStore()?.save(Started(config: config, logLevel: options.logLevel))
|
|
72
|
+
|
|
73
|
+
guard afresh || credential != nil else {
|
|
74
|
+
proceed(core, options)
|
|
75
|
+
return core
|
|
76
|
+
}
|
|
77
|
+
// A new identity (wiped first) or a credential to keep, before anything of the engine's runs.
|
|
78
|
+
Task {
|
|
79
|
+
if afresh {
|
|
80
|
+
BubblLog.info("Bubbl starts afresh as a new device")
|
|
81
|
+
await runner.cancelAll()
|
|
82
|
+
do { try await core.wipeLocalData() } catch { BubblLog.warning("What an earlier device kept here couldn't all be removed yet") }
|
|
83
|
+
}
|
|
84
|
+
if let credential {
|
|
85
|
+
do { try core.useCredential(credential) } catch { BubblLog.warning("The credential can't be kept until the device is unlocked") }
|
|
86
|
+
}
|
|
87
|
+
proceed(core, options)
|
|
88
|
+
}
|
|
89
|
+
return core
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// What every start does once the engine is set up: the app's options, then whatever may run.
|
|
93
|
+
private func proceed(_ core: EngineCore, _ options: BubblOptions) {
|
|
94
|
+
do {
|
|
95
|
+
try core.privacy.update { $0.requireConsent = options.requireConsent }
|
|
96
|
+
} catch {
|
|
97
|
+
BubblLog.info("Bubbl's settings can't be read until the device is unlocked: it starts then")
|
|
98
|
+
}
|
|
99
|
+
if let segments = options.segments { _ = try? core.segments.set(segments) }
|
|
100
|
+
run(core, launchFinished: false)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// What the device was started as before; an install from before identities were kept, started
|
|
104
|
+
/// with an API key, counts as one.
|
|
105
|
+
/// An identity kept before it named the key ("api_key" alone) is read with the key the last
|
|
106
|
+
/// start saved, so a key change is seen from the first update too.
|
|
107
|
+
private func previousIdentity() -> String? {
|
|
108
|
+
let started = (try? startedStore()?.load()) ?? nil
|
|
109
|
+
let lastKey = started?.config.apiKey.map { EngineStart.identity(EngineConfig(apiKey: $0, baseUrl: ""), nil) }
|
|
110
|
+
if let kept = (try? identityStore()?.load()) ?? nil { return kept == EngineStart.apiKey ? lastKey ?? kept : kept }
|
|
111
|
+
return lastKey
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// The server refused the credential Bubbl was started with: stopped, and staying stopped (no
|
|
115
|
+
/// self-restart) until the app starts Bubbl with a new one. Nothing is wiped: that start decides.
|
|
116
|
+
/// The app is told (BubblEvent.credentialRejected) and diagnostics say so.
|
|
117
|
+
func credentialRejected(_ core: EngineCore) {
|
|
118
|
+
guard current === core else { return }
|
|
119
|
+
try? rejectedStore()?.save(true)
|
|
120
|
+
BubblLog.error("The device's credential was refused: Bubbl has stopped until it's started with a new one")
|
|
121
|
+
stop()
|
|
122
|
+
BubblEvents.shared.emit(.credentialRejected)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/// Started with a credential the server has refused.
|
|
126
|
+
var isCredentialRejected: Bool { ((try? rejectedStore()?.load()) ?? nil) == true }
|
|
127
|
+
|
|
128
|
+
/// The end of every launch (BubblLaunch's +load watches for it): when an earlier launch started
|
|
129
|
+
/// Bubbl and it wasn't stopped since, it starts again now, as it was, so an app launched for a
|
|
130
|
+
/// geofence, a push or a tap has it before the app's own code (a wrapper's JavaScript or Dart)
|
|
131
|
+
/// runs. The app's own `start` later changes nothing, or applies its new options.
|
|
132
|
+
func applicationDidFinishLaunching() {
|
|
133
|
+
guard current == nil else { return }
|
|
134
|
+
// Before the first unlock after a reboot it can't be read: nothing starts (such events are lost).
|
|
135
|
+
guard let saved = (try? startedStore()?.load()) ?? nil else { return }
|
|
136
|
+
SystemLog.use(saved.logLevel)
|
|
137
|
+
guard let core = engine(for: saved.config) else { return }
|
|
138
|
+
BubblLog.info("Bubbl started itself for this launch, as the app last started it")
|
|
139
|
+
run(core, launchFinished: true)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// The engine for `config`: the running one if it's for the same (and not `fresh`), a new one
|
|
143
|
+
/// otherwise.
|
|
144
|
+
private func engine(for config: EngineConfig, fresh: Bool = false) -> EngineCore? {
|
|
145
|
+
BubblLaunchLinked()
|
|
146
|
+
guard let directory = Self.directory() else {
|
|
147
|
+
BubblLog.error("Bubbl couldn't create its folder in Application Support")
|
|
148
|
+
return nil
|
|
149
|
+
}
|
|
150
|
+
return lock.sync {
|
|
151
|
+
if !fresh, let existing = self.core, existing.config == config { return existing }
|
|
152
|
+
let stores = EngineStores.files(in: directory, writeOptions: Self.writeOptions, credentials: KeychainCredentialStore())
|
|
153
|
+
let created = EngineCore(config: config, stores: stores, platform: platform, http: URLSessionHttpClient(), monitor: Self.regionMonitor())
|
|
154
|
+
created.onCredentialRejected { [weak created] in
|
|
155
|
+
guard let created else { return }
|
|
156
|
+
EngineHost.shared.credentialRejected(created)
|
|
157
|
+
}
|
|
158
|
+
self.core = created
|
|
159
|
+
return created
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Location, push and the app's comings and goings, then whatever work the engine may do.
|
|
164
|
+
private func run(_ core: EngineCore, launchFinished: Bool) {
|
|
165
|
+
startLocation()
|
|
166
|
+
#if os(iOS)
|
|
167
|
+
PushIntegration.installWhenLaunched(launchFinished: launchFinished)
|
|
168
|
+
refreshPermissions()
|
|
169
|
+
#endif
|
|
170
|
+
observeTheApp()
|
|
171
|
+
resume(core)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private func startedStore() -> FileValueStore<Started>? {
|
|
175
|
+
Self.directory().map { FileValueStore(url: $0.appendingPathComponent("started.json"), writeOptions: Self.writeOptions) }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// What the device was last started as (EngineStart.identity); outlives `stop`.
|
|
179
|
+
private func identityStore() -> FileValueStore<String>? {
|
|
180
|
+
Self.directory().map { FileValueStore(url: $0.appendingPathComponent("identity.json"), writeOptions: Self.writeOptions) }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private func rejectedStore() -> FileValueStore<Bool>? {
|
|
184
|
+
Self.directory().map { FileValueStore(url: $0.appendingPathComponent("credential_rejected.json"), writeOptions: Self.writeOptions) }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// Stop until start is called again: nothing more runs, no geofences are watched, and Bubbl
|
|
188
|
+
/// doesn't start itself at the next launch (what it was started as is kept, so the next start
|
|
189
|
+
/// still knows whether it's the same device); nothing is dropped or told to the server.
|
|
190
|
+
func stop() {
|
|
191
|
+
try? startedStore()?.delete()
|
|
192
|
+
let stopped: EngineCore? = lock.sync {
|
|
193
|
+
defer { core = nil }
|
|
194
|
+
return core
|
|
195
|
+
}
|
|
196
|
+
Task {
|
|
197
|
+
await runner.cancelAll()
|
|
198
|
+
try? await stopped?.geofences.stop()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// Whatever the engine may do now: the device, the queue, segments, the config.
|
|
203
|
+
func resume(_ core: EngineCore) {
|
|
204
|
+
if !core.sdkSupported {
|
|
205
|
+
BubblLog.error("This SDK (\(BubblVersion.sdk)) is older than the workspace's minimum: update it to use Bubbl")
|
|
206
|
+
} else if !core.isActive {
|
|
207
|
+
BubblLog.info(core.privacy.state?.active == true ? "Bubbl is paused by the server" : "Bubbl is waiting for consent")
|
|
208
|
+
}
|
|
209
|
+
resumePrivacyWork(core)
|
|
210
|
+
// Maintenance runs whatever the state: it's how an SDK below the minimum comes back.
|
|
211
|
+
submit("maintenance") { await $0.maintenance(configMaxAgeSeconds: EngineCore.openConfigMaxAgeSeconds) }
|
|
212
|
+
guard core.isActive else { return }
|
|
213
|
+
submit("device") { await $0.syncDevice() }
|
|
214
|
+
submit("events") { await $0.flushEvents() }
|
|
215
|
+
submit("segments") { await $0.pushSegments() }
|
|
216
|
+
if core.locationActive { checkGeofences() }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// Runs `work` against whichever engine is current when it runs (none after `stop`).
|
|
220
|
+
func submit(_ name: String, _ work: @escaping @Sendable (EngineCore) async -> WorkOutcome) {
|
|
221
|
+
Task {
|
|
222
|
+
await runner.submit(name) {
|
|
223
|
+
guard let core = self.current else { return .done }
|
|
224
|
+
return await work(core)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// MARK: - The app
|
|
230
|
+
|
|
231
|
+
private func observeTheApp() {
|
|
232
|
+
let first: Bool = lock.sync {
|
|
233
|
+
defer { observing = true }
|
|
234
|
+
return !observing
|
|
235
|
+
}
|
|
236
|
+
guard first else { return }
|
|
237
|
+
|
|
238
|
+
#if canImport(UIKit) && !os(watchOS)
|
|
239
|
+
let center = NotificationCenter.default
|
|
240
|
+
// Every unlock, the first after a reboot included: held work may be able to run now. (Not
|
|
241
|
+
// protectedDataWillBecomeUnavailable, nor isProtectedDataAvailable: those follow the
|
|
242
|
+
// Complete class, which closes at every lock, while the engine's data stays readable.)
|
|
243
|
+
center.addObserver(forName: UIApplication.protectedDataDidBecomeAvailableNotification, object: nil, queue: nil) { [weak self] _ in
|
|
244
|
+
guard let self else { return }
|
|
245
|
+
Task { await self.runner.recheck() }
|
|
246
|
+
}
|
|
247
|
+
center.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { [weak self] _ in
|
|
248
|
+
self?.appOpened()
|
|
249
|
+
}
|
|
250
|
+
center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in
|
|
251
|
+
MainActor.assumeIsolated { self?.appWentToTheBack() }
|
|
252
|
+
}
|
|
253
|
+
Task { @MainActor [weak self] in
|
|
254
|
+
// Launched into the front (not woken in the background): that's an app open.
|
|
255
|
+
if UIApplication.shared.applicationState != .background { self?.appOpened() }
|
|
256
|
+
}
|
|
257
|
+
#endif
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Where the device stands on permissions, for PUT /device and `permissions.status()` (the
|
|
261
|
+
/// person may have changed them in Settings while the app was away).
|
|
262
|
+
func refreshPermissions() {
|
|
263
|
+
#if os(iOS)
|
|
264
|
+
Task { @MainActor in _ = await PermissionFlow.shared.status() }
|
|
265
|
+
#endif
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// The app came to the front: app.opened, the queue, and the config if it's due.
|
|
269
|
+
private func appOpened() {
|
|
270
|
+
refreshPermissions()
|
|
271
|
+
#if os(iOS)
|
|
272
|
+
// Notifications may have been allowed since (by the app, a plugin, Settings): the device token
|
|
273
|
+
// then, not only at the next launch. (With the integration off, the app hands it over.)
|
|
274
|
+
PushIntegration.registerIfAllowed()
|
|
275
|
+
#endif
|
|
276
|
+
submit("app.opened") { core in
|
|
277
|
+
// Pushes iOS never delivered: pulled, and shown in front.
|
|
278
|
+
for notification in await core.appOpened(pull: true) {
|
|
279
|
+
_ = await self.show(notification, received: true, opened: false)
|
|
280
|
+
}
|
|
281
|
+
return .done
|
|
282
|
+
}
|
|
283
|
+
submit("events") { await $0.flushEvents() }
|
|
284
|
+
submit("maintenance") { await $0.maintenance(configMaxAgeSeconds: EngineCore.openConfigMaxAgeSeconds) }
|
|
285
|
+
appOpenedForLocation()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#if canImport(UIKit) && !os(watchOS)
|
|
289
|
+
/// The app went to the back: send what's queued in the time iOS allows.
|
|
290
|
+
@MainActor
|
|
291
|
+
private func appWentToTheBack() {
|
|
292
|
+
appWentToTheBackForLocation()
|
|
293
|
+
guard let core = current, core.isActive else { return }
|
|
294
|
+
BackgroundRefresh.schedule()
|
|
295
|
+
let time = BackgroundTime("tech.bubbl.sdk.flush")
|
|
296
|
+
Task {
|
|
297
|
+
// Not sent in time: the runner retries it, with its backoff, when the app next runs.
|
|
298
|
+
if case .retry = await core.flushEvents() { submit("events") { await $0.flushEvents() } }
|
|
299
|
+
time.end()
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
#endif
|
|
303
|
+
|
|
304
|
+
// MARK: - Files
|
|
305
|
+
|
|
306
|
+
/// Application Support/tech.bubbl.sdk, kept out of backups: a restored install id would make two
|
|
307
|
+
/// phones one device, and a restored geofence state would claim places the device isn't in.
|
|
308
|
+
static func directory() -> URL? {
|
|
309
|
+
guard let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil }
|
|
310
|
+
var directory = base.appendingPathComponent("tech.bubbl.sdk", isDirectory: true)
|
|
311
|
+
do {
|
|
312
|
+
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
313
|
+
var values = URLResourceValues()
|
|
314
|
+
values.isExcludedFromBackup = true
|
|
315
|
+
try directory.setResourceValues(values)
|
|
316
|
+
} catch {
|
|
317
|
+
// Already there (and excluded), or not writable yet: the stores say which when used.
|
|
318
|
+
}
|
|
319
|
+
return directory
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/// Whether the engine's files (and the Keychain item, whose class matches) can be read now,
|
|
323
|
+
/// tried on a file of their protection class: before the first unlock after a reboot an
|
|
324
|
+
/// existing one can't be read and a new one can't be made.
|
|
325
|
+
static func dataReadable(in directory: URL? = directory()) -> Bool {
|
|
326
|
+
guard let directory else { return false }
|
|
327
|
+
let probe = directory.appendingPathComponent("readable")
|
|
328
|
+
if (try? Data(contentsOf: probe)) != nil { return true }
|
|
329
|
+
guard !FileManager.default.fileExists(atPath: probe.path) else { return false }
|
|
330
|
+
do {
|
|
331
|
+
try Data([1]).write(to: probe, options: writeOptions.union(.atomic))
|
|
332
|
+
return true
|
|
333
|
+
} catch {
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/// Readable from the first unlock after a reboot until the next reboot (region events in the
|
|
339
|
+
/// background need that); unreadable before it.
|
|
340
|
+
static var writeOptions: Data.WritingOptions {
|
|
341
|
+
#if os(iOS)
|
|
342
|
+
[.completeFileProtectionUntilFirstUserAuthentication]
|
|
343
|
+
#else
|
|
344
|
+
[]
|
|
345
|
+
#endif
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/// Region monitoring until the location slice (7): watches nothing, as for a user who allowed
|
|
350
|
+
/// location only while the app is in use.
|
|
351
|
+
@available(iOS 17, *)
|
|
352
|
+
struct InactiveRegionMonitor: RegionMonitor {
|
|
353
|
+
func capacity() async -> Int { 0 }
|
|
354
|
+
func watch(_ regions: [WatchedRegion]) async -> Bool { true }
|
|
355
|
+
func stop() async {}
|
|
356
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
#if canImport(UIKit)
|
|
3
|
+
import UIKit
|
|
4
|
+
#endif
|
|
5
|
+
#if canImport(UserNotifications)
|
|
6
|
+
import UserNotifications
|
|
7
|
+
#endif
|
|
8
|
+
#if !COCOAPODS
|
|
9
|
+
import BubblCore
|
|
10
|
+
#endif
|
|
11
|
+
|
|
12
|
+
/// `Bubbl`'s calls on iOS 17 and later: the engine.
|
|
13
|
+
@available(iOS 17, *)
|
|
14
|
+
struct SupportedBubbl: BubblBackend {
|
|
15
|
+
var isSupported: Bool { true }
|
|
16
|
+
|
|
17
|
+
// MARK: - Starting
|
|
18
|
+
|
|
19
|
+
func start(apiKey: String, options: BubblOptions) {
|
|
20
|
+
let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
21
|
+
var baseUrl = options.baseUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
22
|
+
while baseUrl.hasSuffix("/") { baseUrl.removeLast() }
|
|
23
|
+
|
|
24
|
+
guard !key.isEmpty else {
|
|
25
|
+
SystemLog.use(options.logLevel)
|
|
26
|
+
return BubblLog.error("Bubbl.start: the API key is empty")
|
|
27
|
+
}
|
|
28
|
+
guard Bubbl.isAllowedBaseUrl(baseUrl) else {
|
|
29
|
+
SystemLog.use(options.logLevel)
|
|
30
|
+
return BubblLog.error("Bubbl.start: baseUrl must be https:// (http:// only for localhost or a .test host)")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
guard let core = EngineHost.shared.start(EngineConfig(apiKey: key, baseUrl: baseUrl), options: options) else { return }
|
|
34
|
+
BubblLog.info("Bubbl \(BubblVersion.sdk) started")
|
|
35
|
+
if let warning = EngineStart.keyWarning(apiKey: key, debugBuild: Self.debugBuild) { BubblLog.warning(warning) }
|
|
36
|
+
core.announceSandbox()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// The app's build, as the SDK was built with it (a Swift package builds in the app's
|
|
40
|
+
/// configuration): for the key-and-build warning.
|
|
41
|
+
static var debugBuild: Bool {
|
|
42
|
+
#if DEBUG
|
|
43
|
+
true
|
|
44
|
+
#else
|
|
45
|
+
false
|
|
46
|
+
#endif
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func start(credential: BubblCredential, options: BubblOptions) {
|
|
50
|
+
var baseUrl = options.baseUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
51
|
+
while baseUrl.hasSuffix("/") { baseUrl.removeLast() }
|
|
52
|
+
|
|
53
|
+
guard [credential.keyId, credential.secret, credential.installId].allSatisfy({ !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else {
|
|
54
|
+
SystemLog.use(options.logLevel)
|
|
55
|
+
return BubblLog.error("Bubbl.start: the credential is incomplete")
|
|
56
|
+
}
|
|
57
|
+
guard Bubbl.isAllowedBaseUrl(baseUrl) else {
|
|
58
|
+
SystemLog.use(options.logLevel)
|
|
59
|
+
return BubblLog.error("Bubbl.start: baseUrl must be https:// (http:// only for localhost or a .test host)")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
guard let core = EngineHost.shared.start(EngineConfig(apiKey: nil, baseUrl: baseUrl), credential: credential.issued, options: options) else { return }
|
|
63
|
+
BubblLog.info("Bubbl \(BubblVersion.sdk) started with a credential")
|
|
64
|
+
core.announceSandbox()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func stop() {
|
|
68
|
+
EngineHost.shared.stop()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// MARK: - Consent and privacy
|
|
72
|
+
|
|
73
|
+
func setConsent(_ granted: Bool) {
|
|
74
|
+
guard granted else { return optOut() }
|
|
75
|
+
guard let core = started("setConsent") else { return }
|
|
76
|
+
do {
|
|
77
|
+
if try core.grantConsent() { EngineHost.shared.consentGiven(core) }
|
|
78
|
+
} catch {
|
|
79
|
+
BubblLog.warning("Bubbl.setConsent can't be saved until the device is unlocked: ask again then")
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
func optOut() {
|
|
84
|
+
guard let core = started("optOut") else { return }
|
|
85
|
+
EngineHost.shared.optOut(core)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func deleteMyData() {
|
|
89
|
+
guard let core = started("deleteMyData") else { return }
|
|
90
|
+
EngineHost.shared.erase(core)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
func setLocationEnabled(_ enabled: Bool) {
|
|
94
|
+
guard let core = started("setLocationEnabled") else { return }
|
|
95
|
+
EngineHost.shared.setLocationEnabled(enabled, core)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func registerTestDevice(_ code: String) async -> BubblTestDeviceResult {
|
|
99
|
+
guard let core = started("registerTestDevice") else {
|
|
100
|
+
return BubblTestDeviceResult(approved: false, message: "Bubbl.start hasn't been called")
|
|
101
|
+
}
|
|
102
|
+
let result = await core.registerTestDevice(code)
|
|
103
|
+
return BubblTestDeviceResult(approved: result.approved, message: result.message)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// MARK: - Targeting and events
|
|
107
|
+
|
|
108
|
+
func setSegments(_ segments: [String]) {
|
|
109
|
+
guard let core = started("setSegments") else { return }
|
|
110
|
+
do {
|
|
111
|
+
if try core.segments.set(segments) { EngineHost.shared.submit("segments") { await $0.pushSegments() } }
|
|
112
|
+
} catch {
|
|
113
|
+
BubblLog.warning("Bubbl.setSegments: they can't be saved until the device is unlocked")
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func track(_ name: String, properties: [String: Any?]) {
|
|
118
|
+
guard let core = started("track") else { return }
|
|
119
|
+
var converted: [String: JSONValue] = [:]
|
|
120
|
+
for (key, value) in properties {
|
|
121
|
+
if let json = JSONValue(app: value) { converted[key] = json } else {
|
|
122
|
+
BubblLog.warning("Bubbl.track: a property that isn't text, a number or true/false was left out")
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
Task {
|
|
126
|
+
if await core.track(name, properties: converted) {
|
|
127
|
+
EngineHost.shared.submit("events") { await $0.flushEvents() }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// MARK: - Notifications
|
|
133
|
+
|
|
134
|
+
func setNotificationListener(_ listener: (@MainActor @Sendable (BubblMessage) -> Bool)?) {
|
|
135
|
+
EngineHost.shared.notificationListener = listener
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func addEventListener(_ listener: @escaping @MainActor @Sendable (BubblEvent) -> Void) -> BubblEventToken {
|
|
139
|
+
BubblEvents.shared.add(listener)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func removeEventListener(_ token: BubblEventToken) {
|
|
143
|
+
BubblEvents.shared.remove(token)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func present(_ message: BubblMessage) {
|
|
147
|
+
guard started("present") != nil else { return }
|
|
148
|
+
Task { _ = await EngineHost.shared.presenter.present(message.notification.json, opened: false) }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Records a notification event the app reports (and tells its event listeners), then sends
|
|
152
|
+
/// the queue.
|
|
153
|
+
func report(_ call: String, _ record: @escaping @Sendable (NotificationEvents) async throws -> Void) {
|
|
154
|
+
guard let core = started(call), core.isActive else { return }
|
|
155
|
+
let events = EngineHost.shared.notificationEvents(core)
|
|
156
|
+
Task {
|
|
157
|
+
do {
|
|
158
|
+
try await record(events)
|
|
159
|
+
EngineHost.shared.submit("events") { await $0.flushEvents() }
|
|
160
|
+
} catch {
|
|
161
|
+
BubblLog.warning("Bubbl.\(call): not recorded (\(type(of: error)))")
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func submitSurvey(_ message: BubblMessage, answers: [String: Any?]) -> Bool {
|
|
167
|
+
var converted: [String: JSONValue] = [:]
|
|
168
|
+
for (id, value) in answers {
|
|
169
|
+
guard let json = JSONValue(app: value) else {
|
|
170
|
+
BubblLog.warning("Bubbl.submitSurvey: the answer to \(id) isn't a choice id, a list of them, a number, true/false or text")
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
converted[id] = json
|
|
174
|
+
}
|
|
175
|
+
switch SurveyForm.fromAnswers(message.notification.questions, converted) {
|
|
176
|
+
case .failure(let problem):
|
|
177
|
+
BubblLog.warning("Bubbl.submitSurvey: \(problem)")
|
|
178
|
+
return false
|
|
179
|
+
case .success(let form):
|
|
180
|
+
report("submitSurvey") { try await $0.surveySubmitted(message.notification, form) }
|
|
181
|
+
return true
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func openCta(_ message: BubblMessage) {
|
|
186
|
+
guard let link = message.ctaUrl, LinkPolicy.canOpen(link), let url = URL(string: link) else { return }
|
|
187
|
+
report("reportCtaClicked") { try await $0.ctaClicked(message.notification) }
|
|
188
|
+
#if canImport(UIKit) && !os(watchOS)
|
|
189
|
+
Task { @MainActor in UIApplication.shared.open(url) }
|
|
190
|
+
#endif
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// MARK: - Push
|
|
194
|
+
|
|
195
|
+
func isBubblMessage(_ userInfo: [AnyHashable: Any]) -> Bool {
|
|
196
|
+
PushMessage.isBubbl(EngineHost.pushData(userInfo))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#if os(iOS)
|
|
200
|
+
func setPushToken(_ deviceToken: Data) {
|
|
201
|
+
EngineHost.shared.pushTokenReceived(deviceToken)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool {
|
|
205
|
+
NotificationDelegateHooks.willPresent(notification, completionHandler)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool {
|
|
209
|
+
NotificationDelegateHooks.didReceive(response, completionHandler)
|
|
210
|
+
}
|
|
211
|
+
#endif
|
|
212
|
+
|
|
213
|
+
// MARK: - Permissions
|
|
214
|
+
|
|
215
|
+
func permissionStatus() -> BubblPermissionStatus? {
|
|
216
|
+
guard EngineHost.shared.current != nil else { return nil }
|
|
217
|
+
return EngineHost.shared.platform.permissionStatus.map(BubblPermissionStatus.init)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func requestPermission(_ request: PermissionRequest, _ call: String) async -> BubblPermissionStatus? {
|
|
221
|
+
guard EngineHost.shared.current != nil else {
|
|
222
|
+
BubblLog.warning("Bubbl.\(call) before Bubbl.start: ignored")
|
|
223
|
+
return nil
|
|
224
|
+
}
|
|
225
|
+
#if os(iOS)
|
|
226
|
+
return BubblPermissionStatus(await PermissionFlow.shared.request(request))
|
|
227
|
+
#else
|
|
228
|
+
return nil
|
|
229
|
+
#endif
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// No engine needed, so no `start` check: an app can send the user to its settings page before
|
|
233
|
+
/// starting Bubbl.
|
|
234
|
+
func openSettings() {
|
|
235
|
+
#if os(iOS)
|
|
236
|
+
Task { @MainActor in PermissionFlow.openSettings() }
|
|
237
|
+
#endif
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// MARK: - Diagnostics
|
|
241
|
+
|
|
242
|
+
func diagnostics() async -> Bubbl.Diagnostics {
|
|
243
|
+
let backgroundRefresh = await Self.backgroundRefreshAvailable()
|
|
244
|
+
let lowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
|
|
245
|
+
guard let core = EngineHost.shared.current else {
|
|
246
|
+
return Bubbl.Diagnostics(
|
|
247
|
+
started: false, supported: true, sdkVersion: Bubbl.sdkVersion, sdkSupported: true, registered: false, installId: nil, active: false,
|
|
248
|
+
pausedUntil: nil, consentRequired: false, consent: nil, locationEnabled: true, permissions: nil, hasPushToken: false,
|
|
249
|
+
geofences: 0, queuedEvents: 0, lastError: BubblLog.lastError, credentialRejected: EngineHost.shared.isCredentialRejected, backgroundLocation: false,
|
|
250
|
+
transitionsDroppedWhileLocked: 0, backgroundRefreshAvailable: backgroundRefresh, lowPowerMode: lowPower,
|
|
251
|
+
osWatchesGeofences: false, environment: nil, testDevice: nil
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
let privacy = core.privacy.state
|
|
255
|
+
return Bubbl.Diagnostics(
|
|
256
|
+
started: true,
|
|
257
|
+
supported: true,
|
|
258
|
+
sdkVersion: Bubbl.sdkVersion,
|
|
259
|
+
sdkSupported: core.sdkSupported,
|
|
260
|
+
registered: core.everRegistered,
|
|
261
|
+
installId: core.keptInstallId,
|
|
262
|
+
active: core.isActive,
|
|
263
|
+
pausedUntil: core.pausedUntilSeconds,
|
|
264
|
+
consentRequired: privacy?.requireConsent ?? false,
|
|
265
|
+
consent: privacy?.consent,
|
|
266
|
+
locationEnabled: privacy?.locationEnabled ?? true,
|
|
267
|
+
permissions: permissionStatus(),
|
|
268
|
+
hasPushToken: EngineHost.shared.platform.pushToken() != nil,
|
|
269
|
+
geofences: (try? await core.geofences.geofenceCount()) ?? 0,
|
|
270
|
+
queuedEvents: (try? await core.events.size()) ?? 0,
|
|
271
|
+
lastError: BubblLog.lastError,
|
|
272
|
+
credentialRejected: EngineHost.shared.isCredentialRejected,
|
|
273
|
+
backgroundLocation: await Self.backgroundLocation(),
|
|
274
|
+
transitionsDroppedWhileLocked: core.transitionsDroppedWhileLocked,
|
|
275
|
+
backgroundRefreshAvailable: backgroundRefresh,
|
|
276
|
+
lowPowerMode: lowPower,
|
|
277
|
+
osWatchesGeofences: await Self.osWatchesGeofences(),
|
|
278
|
+
environment: core.workspace?.environment,
|
|
279
|
+
testDevice: core.workspace?.testDevice.map { Bubbl.Diagnostics.TestDevice(status: $0.status, code: $0.code) }
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// MARK: - Internals
|
|
284
|
+
|
|
285
|
+
private func started(_ call: String) -> EngineCore? {
|
|
286
|
+
guard let core = EngineHost.shared.current else {
|
|
287
|
+
BubblLog.warning("Bubbl.\(call) before Bubbl.start: ignored")
|
|
288
|
+
return nil
|
|
289
|
+
}
|
|
290
|
+
return core
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/// "Always", with precise location: iOS watches the geofences with the app closed.
|
|
294
|
+
@MainActor
|
|
295
|
+
private static func backgroundLocation() -> Bool {
|
|
296
|
+
#if os(iOS)
|
|
297
|
+
LocationService.shared.access == .always
|
|
298
|
+
#else
|
|
299
|
+
false
|
|
300
|
+
#endif
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
@MainActor
|
|
304
|
+
private static func osWatchesGeofences() -> Bool {
|
|
305
|
+
#if os(iOS)
|
|
306
|
+
LocationService.shared.osWatches
|
|
307
|
+
#else
|
|
308
|
+
false
|
|
309
|
+
#endif
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
@MainActor
|
|
313
|
+
private static func backgroundRefreshAvailable() -> Bool {
|
|
314
|
+
#if canImport(UIKit) && !os(watchOS)
|
|
315
|
+
UIApplication.shared.backgroundRefreshStatus == .available
|
|
316
|
+
#else
|
|
317
|
+
false
|
|
318
|
+
#endif
|
|
319
|
+
}
|
|
320
|
+
}
|