@bubblsdk/react-native-sdk 4.1.7 → 5.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/BubblReactNativeSdk.podspec +29 -9
  2. package/CHANGELOG.md +52 -1
  3. package/LICENSE +202 -1
  4. package/README.md +172 -83
  5. package/android/build.gradle +33 -30
  6. package/android/src/main/AndroidManifest.xml +1 -0
  7. package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
  8. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
  9. package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
  10. package/app.plugin.js +2 -0
  11. package/ios/BubblPush.swift +44 -0
  12. package/ios/BubblSdk.h +7 -0
  13. package/ios/BubblSdk.mm +252 -0
  14. package/ios/BubblSdkImpl.swift +335 -0
  15. package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
  16. package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
  17. package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
  18. package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
  19. package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
  20. package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
  21. package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
  22. package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
  23. package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
  24. package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
  25. package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
  26. package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
  27. package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
  28. package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
  29. package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
  30. package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
  31. package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
  32. package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
  33. package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
  34. package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
  35. package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
  36. package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  37. package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
  38. package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
  39. package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
  40. package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
  41. package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
  42. package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
  43. package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
  44. package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
  45. package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
  46. package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
  47. package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
  48. package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
  49. package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
  50. package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
  51. package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
  52. package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
  53. package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
  54. package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
  55. package/ios-sdk/BubblCore/API/Http.swift +39 -0
  56. package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
  57. package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
  58. package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
  59. package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
  60. package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
  61. package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
  62. package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
  63. package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
  64. package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
  65. package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
  66. package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
  67. package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
  68. package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
  69. package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
  70. package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
  71. package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
  72. package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
  73. package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
  74. package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
  75. package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
  76. package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
  77. package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
  78. package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
  79. package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
  80. package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
  81. package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
  82. package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
  83. package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
  84. package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
  85. package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
  86. package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
  87. package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
  88. package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
  89. package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
  90. package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
  91. package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
  92. package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
  93. package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
  94. package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
  95. package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
  96. package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
  97. package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
  98. package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
  99. package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
  100. package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
  101. package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
  102. package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
  103. package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
  104. package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
  105. package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
  106. package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
  107. package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
  108. package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
  109. package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
  110. package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
  111. package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
  112. package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
  113. package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
  114. package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
  115. package/jest/index.js +75 -0
  116. package/lib/commonjs/BubblModule.js +14 -0
  117. package/lib/commonjs/BubblModule.js.map +1 -0
  118. package/lib/commonjs/BubblModule.web.js +13 -0
  119. package/lib/commonjs/BubblModule.web.js.map +1 -0
  120. package/lib/commonjs/NativeBubblSdk.js +20 -0
  121. package/lib/commonjs/NativeBubblSdk.js.map +1 -0
  122. package/lib/commonjs/index.js +336 -0
  123. package/lib/commonjs/index.js.map +1 -0
  124. package/lib/commonjs/package.json +1 -0
  125. package/lib/commonjs/types.js +2 -0
  126. package/lib/commonjs/types.js.map +1 -0
  127. package/lib/module/BubblModule.js +5 -0
  128. package/lib/module/BubblModule.js.map +1 -0
  129. package/lib/module/BubblModule.web.js +9 -0
  130. package/lib/module/BubblModule.web.js.map +1 -0
  131. package/lib/module/NativeBubblSdk.js +18 -0
  132. package/lib/module/NativeBubblSdk.js.map +1 -0
  133. package/lib/module/index.js +331 -0
  134. package/lib/module/index.js.map +1 -0
  135. package/lib/module/package.json +1 -0
  136. package/lib/module/types.js +2 -0
  137. package/lib/module/types.js.map +1 -0
  138. package/lib/typescript/commonjs/package.json +1 -0
  139. package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
  140. package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
  141. package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
  142. package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
  143. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
  144. package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
  145. package/lib/typescript/commonjs/src/index.d.ts +111 -0
  146. package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
  147. package/lib/typescript/commonjs/src/types.d.ts +192 -0
  148. package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
  149. package/lib/typescript/module/package.json +1 -0
  150. package/lib/typescript/module/src/BubblModule.d.ts +3 -0
  151. package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
  152. package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
  153. package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
  154. package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
  155. package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
  156. package/lib/typescript/module/src/index.d.ts +111 -0
  157. package/lib/typescript/module/src/index.d.ts.map +1 -0
  158. package/lib/typescript/module/src/types.d.ts +192 -0
  159. package/lib/typescript/module/src/types.d.ts.map +1 -0
  160. package/package.json +105 -24
  161. package/plugin/build/imageNotifications.d.ts +35 -0
  162. package/plugin/build/imageNotifications.js +198 -0
  163. package/plugin/build/index.d.ts +84 -0
  164. package/plugin/build/index.js +176 -0
  165. package/src/BubblModule.ts +2 -0
  166. package/src/BubblModule.web.ts +9 -0
  167. package/src/NativeBubblSdk.ts +67 -0
  168. package/src/index.ts +338 -282
  169. package/src/types.ts +182 -0
  170. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
  171. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
  172. package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
  173. package/ios/BubblSdk.swift +0 -802
  174. package/ios/BubblSdkBridge.m +0 -86
  175. package/react-native.config.js +0 -11
  176. package/src/specs/NativeBubblSdk.ts +0 -54
@@ -0,0 +1,308 @@
1
+ import Foundation
2
+ #if canImport(UserNotifications)
3
+ import UserNotifications
4
+ #endif
5
+ #if !COCOAPODS
6
+ import BubblCore
7
+ #endif
8
+
9
+ /// Bubbl, for an iOS app: the same functions as Android's `tech.bubbl.sdk.Bubbl`
10
+ /// (docs/PUBLIC_API.md). Two steps to install: add the package, and in
11
+ /// `application(_:didFinishLaunchingWithOptions:)` (or the SwiftUI `App`'s init)
12
+ ///
13
+ /// Bubbl.start(apiKey: "pk_live_…", options: BubblOptions(baseUrl: "https://…"))
14
+ ///
15
+ /// Every call is safe from any thread. Calls before `start` do nothing (and log a warning), except
16
+ /// `permissions.openSettings()`, which needs nothing of Bubbl's. Bubbl never stops the app: a mistake (an empty key, an http:// URL) is logged, not thrown.
17
+ ///
18
+ /// Bubbl works on iOS 17 and later. An app supporting older versions can still include it: on
19
+ /// those phones `isSupported` is false and every call does nothing (see `isSupported`).
20
+ public enum Bubbl {
21
+ /// This SDK's version.
22
+ public static let sdkVersion = BubblVersion.sdk
23
+
24
+ /// Whether Bubbl works on this device (iOS 17 and later). When false, `start` logs that it
25
+ /// does nothing here and returns: nothing is installed or sent, nothing prompts, no
26
+ /// notification delegate or background task is set up, and no location is used. Every other
27
+ /// call does nothing too, permission calls report nil, notification listeners are never
28
+ /// called, and `diagnostics()` says `supported: false`.
29
+ public static var isSupported: Bool { backend.isSupported }
30
+
31
+ // MARK: - Starting
32
+
33
+ /// Start Bubbl. Call once per launch, early (so background launches for a geofence or a push
34
+ /// have it too); calling again with the same key and options changes nothing.
35
+ public static func start(apiKey: String, options: BubblOptions) {
36
+ backend.start(apiKey: apiKey, options: options)
37
+ }
38
+
39
+ /// Start Bubbl with a device credential issued outside the app (installs provisioned ahead of
40
+ /// time, an app's own pairing flow) instead of an API key: the device never registers itself.
41
+ /// As `start(apiKey:options:)` otherwise, and called the same way at every launch. A different
42
+ /// credential than last time (or switching from an API key) starts afresh as a new device:
43
+ /// nothing kept from before is sent under the new one. If the server refuses the credential,
44
+ /// Bubbl stops and says so (`BubblEvent.credentialRejected`, diagnostics) until it's started
45
+ /// with a new one.
46
+ public static func start(credential: BubblCredential, options: BubblOptions) {
47
+ backend.start(credential: credential, options: options)
48
+ }
49
+
50
+ /// Stop Bubbl on this device until `start` is called again: nothing more runs; nothing is
51
+ /// dropped, and the server isn't told (unlike `optOut`).
52
+ public static func stop() {
53
+ backend.stop()
54
+ }
55
+
56
+ // MARK: - Consent and privacy
57
+
58
+ /// The user's answer, for apps started with `requireConsent` (and to give consent again after
59
+ /// an opt-out): true starts everything; false is the same as `optOut()`.
60
+ public static func setConsent(_ granted: Bool) {
61
+ backend.setConsent(granted)
62
+ }
63
+
64
+ /// Stop Bubbl for this user: nothing more is sent, shown or tracked, what's queued is dropped,
65
+ /// and the server is told.
66
+ public static func optOut() {
67
+ backend.optOut()
68
+ }
69
+
70
+ /// Erase this device and everything Bubbl recorded about it, on the server and here. It keeps
71
+ /// trying until it's done, and Bubbl stays off afterwards (`setConsent(true)` later starts
72
+ /// afresh, as a new device).
73
+ public static func deleteMyData() {
74
+ backend.deleteMyData()
75
+ }
76
+
77
+ /// Turn Bubbl's use of location (geofences) off, or on again; the rest carries on.
78
+ public static func setLocationEnabled(_ enabled: Bool) {
79
+ backend.setLocationEnabled(enabled)
80
+ }
81
+
82
+ // MARK: - Sandbox
83
+
84
+ /// Approve this device as a Sandbox test device with a registration code from the dashboard
85
+ /// (Test devices › Registration codes; each approves one device, within 24 hours), e.g. from a
86
+ /// hidden debug menu or a device farm's set-up. The other ways need no code in the app: the
87
+ /// dashboard's "Add a test device", or matching the code Bubbl logs at start in Sandbox.
88
+ /// `approved` once it is; otherwise `message` says why, to show whoever typed the code (a
89
+ /// wrong, used or expired code, the Sandbox's 25 test devices taken, before `start`, no
90
+ /// network). Also said in the log.
91
+ public static func registerTestDevice(_ code: String) async -> BubblTestDeviceResult {
92
+ await backend.registerTestDevice(code)
93
+ }
94
+
95
+ /// `registerTestDevice(_:)` for code that can't await; `completion` runs on the main thread.
96
+ public static func registerTestDevice(_ code: String, completion: @escaping @Sendable @MainActor (BubblTestDeviceResult) -> Void) {
97
+ Task {
98
+ let result = await registerTestDevice(code)
99
+ await MainActor.run { completion(result) }
100
+ }
101
+ }
102
+
103
+ // MARK: - Permissions
104
+
105
+ /// The permissions Bubbl uses: where they stand, and asking for them.
106
+ public static let permissions = BubblPermissions()
107
+
108
+ // MARK: - Targeting and events
109
+
110
+ /// The device's segments, for targeting campaigns (replaces any set before).
111
+ public static func setSegments(_ segments: [String]) {
112
+ backend.setSegments(segments)
113
+ }
114
+
115
+ /// Record an event of the app's own, for reports: a `name` of letters, digits and . _ : - (at
116
+ /// most 100), and up to 50 flat `properties` (text, numbers, true/false or nil).
117
+ public static func track(_ name: String, properties: [String: Any?] = [:]) {
118
+ backend.track(name, properties: properties)
119
+ }
120
+
121
+ // MARK: - Notifications
122
+
123
+ /// Have a say over each notification before Bubbl shows it: return true to show it yourself
124
+ /// (then report what happens with `reportDisplayed` and the others), false to let Bubbl. Nil
125
+ /// goes back to Bubbl showing everything. Called on the main thread.
126
+ public static func setNotificationListener(_ listener: (@MainActor @Sendable (BubblMessage) -> Bool)?) {
127
+ backend.setNotificationListener(listener)
128
+ }
129
+
130
+ /// Be told of what Bubbl does as it happens (`BubblEvent`), on the main thread. Keep the token
131
+ /// to remove the listener with.
132
+ @discardableResult
133
+ public static func addEventListener(_ listener: @escaping @MainActor @Sendable (BubblEvent) -> Void) -> BubblEventToken {
134
+ backend.addEventListener(listener)
135
+ }
136
+
137
+ public static func removeEventListener(_ token: BubblEventToken) {
138
+ backend.removeEventListener(token)
139
+ }
140
+
141
+ /// Show `message` in Bubbl's notification screen (one the listener held back, say).
142
+ public static func present(_ message: BubblMessage) {
143
+ backend.present(message)
144
+ }
145
+
146
+ /// For an app drawing notifications itself: it's on screen.
147
+ public static func reportDisplayed(_ message: BubblMessage) {
148
+ backend.report("reportDisplayed") { try await $0.displayed(message.notification) }
149
+ }
150
+
151
+ /// …the user opened it (tapped it, rather than it opening on its own).
152
+ public static func reportOpened(_ message: BubblMessage) {
153
+ backend.report("reportOpened") { try await $0.opened(message.notification) }
154
+ }
155
+
156
+ /// …the user tapped its call to action.
157
+ public static func reportCtaClicked(_ message: BubblMessage) {
158
+ backend.report("reportCtaClicked") { try await $0.ctaClicked(message.notification) }
159
+ }
160
+
161
+ /// …the user closed it without acting.
162
+ public static func reportDismissed(_ message: BubblMessage) {
163
+ backend.report("reportDismissed") { try await $0.dismissed(message.notification) }
164
+ }
165
+
166
+ /// …its media (video, audio, a YouTube video, an image) was shown or started playing.
167
+ public static func reportMediaViewed(_ message: BubblMessage) {
168
+ backend.report("reportMediaViewed") { try await $0.mediaViewed(message.notification, positionSeconds: 0) }
169
+ }
170
+
171
+ /// …its video or audio played to the end, `durationSeconds` long.
172
+ public static func reportMediaCompleted(_ message: BubblMessage, durationSeconds: Double) {
173
+ backend.report("reportMediaCompleted") { try await $0.mediaCompleted(message.notification, positionSeconds: durationSeconds) }
174
+ }
175
+
176
+ /// …the user started answering its survey.
177
+ public static func reportSurveyStarted(_ message: BubblMessage) {
178
+ backend.report("reportSurveyStarted") { try await $0.surveyStarted(message.notification) }
179
+ }
180
+
181
+ /// Send the answers to a survey the app showed itself: `answers` by question id, as each
182
+ /// `BubblQuestion` says. Checked as the server checks them; false (and a warning logged, with
183
+ /// why) when they can't be sent, e.g. a required question unanswered or a rating of 6.
184
+ @discardableResult
185
+ public static func submitSurvey(_ message: BubblMessage, answers: [String: Any?]) -> Bool {
186
+ backend.submitSurvey(message, answers: answers)
187
+ }
188
+
189
+ /// Open `message`'s call to action as Bubbl's screen would, and report the click.
190
+ public static func openCta(_ message: BubblMessage) {
191
+ backend.openCta(message)
192
+ }
193
+
194
+ // MARK: - Push
195
+
196
+ /// Whether a push is Bubbl's (its payload's `userInfo`), so the app's own handling can leave it
197
+ /// alone.
198
+ public static func isBubblMessage(_ userInfo: [AnyHashable: Any]) -> Bool {
199
+ backend.isBubblMessage(userInfo)
200
+ }
201
+
202
+ #if os(iOS)
203
+ // Bubbl takes the device token and its own pushes by itself. With BubblAutoIntegrationEnabled
204
+ // set to NO in Info.plist it doesn't, and the app hands them over with these three.
205
+
206
+ /// The device token, from the app delegate's
207
+ /// `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`. Before `start` too.
208
+ public static func setPushToken(_ deviceToken: Data) {
209
+ backend.setPushToken(deviceToken)
210
+ }
211
+
212
+ /// From the notification center delegate's `userNotificationCenter(_:willPresent:…)`: true
213
+ /// when the push is Bubbl's, and Bubbl calls `completionHandler`; false when it isn't, and the
214
+ /// app's own code decides.
215
+ public static func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool {
216
+ backend.willPresent(notification, completionHandler: completionHandler)
217
+ }
218
+
219
+ /// From the notification center delegate's `userNotificationCenter(_:didReceive:…)`: true when
220
+ /// the tapped notification is Bubbl's, and Bubbl calls `completionHandler`; false when it
221
+ /// isn't.
222
+ public static func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool {
223
+ backend.didReceive(response, completionHandler: completionHandler)
224
+ }
225
+ #endif
226
+
227
+ // MARK: - Diagnostics
228
+
229
+ /// Where Bubbl stands on this device, for support and for an app's own debug screen. The same
230
+ /// fields as Android's, and iOS's own at the end.
231
+ public struct Diagnostics: Sendable, Equatable {
232
+ public let started: Bool
233
+ /// Bubbl works on this device (iOS 17 and later): `Bubbl.isSupported`.
234
+ public let supported: Bool
235
+ public let sdkVersion: String
236
+ /// False when the workspace requires a newer SDK.
237
+ public let sdkSupported: Bool
238
+ public let registered: Bool
239
+ /// The install id the engine keeps (how Dashboard › Active users finds this device); nil
240
+ /// before there is one.
241
+ public let installId: String?
242
+ /// Running: started, consent allows it, not paused by the server, supported.
243
+ public let active: Bool
244
+ /// Unix seconds until which the server paused Bubbl, if it has.
245
+ public let pausedUntil: Int64?
246
+ public let consentRequired: Bool
247
+ public let consent: Bool?
248
+ public let locationEnabled: Bool
249
+ /// Where the device stands on the permissions Bubbl uses (as `permissions.status()`).
250
+ public let permissions: BubblPermissionStatus?
251
+ public let hasPushToken: Bool
252
+ public let geofences: Int
253
+ public let queuedEvents: Int
254
+ public let lastError: String?
255
+ /// Started with a credential the server refused (`Bubbl.start(credential:options:)`).
256
+ public let credentialRejected: Bool
257
+ /// Geofences work with the app closed ("Always"); else only while it's open.
258
+ public let backgroundLocation: Bool
259
+ /// Geofence entries and exits that happened before the first unlock after a reboot, when
260
+ /// they couldn't be kept (always 0 on Android).
261
+ public let transitionsDroppedWhileLocked: Int
262
+ /// iOS: Background App Refresh is on for the app.
263
+ public let backgroundRefreshAvailable: Bool
264
+ /// iOS: Low Power Mode is on (background work is held back).
265
+ public let lowPowerMode: Bool
266
+ /// iOS watches the geofences ("Always", precise, and the device can). Otherwise they're
267
+ /// checked from location changes, which is coarser.
268
+ public let osWatchesGeofences: Bool
269
+ /// The workspace the key belongs to: "sandbox" (pk_test_) or "production" (pk_live_); nil
270
+ /// before the device has registered (or from a server that predates Sandbox).
271
+ public let environment: String?
272
+ /// In Sandbox, where this device stands as a test device; nil in Production.
273
+ public let testDevice: TestDevice?
274
+
275
+ /// A Sandbox test device: "pending" until someone approves it (in the dashboard, with
276
+ /// `code`, or with `Bubbl.registerTestDevice`), then "approved". Unused for 30 days, or
277
+ /// removed in the dashboard, it's pending again.
278
+ public struct TestDevice: Sendable, Equatable {
279
+ public let status: String
280
+ /// While pending: the short code the dashboard lists this device under (e.g. K7Q-2MX).
281
+ public let code: String?
282
+ }
283
+ }
284
+
285
+ public static func diagnostics() async -> Diagnostics {
286
+ await backend.diagnostics()
287
+ }
288
+
289
+ /// `diagnostics()` for code that can't await; `completion` runs on the main thread.
290
+ public static func diagnostics(completion: @escaping @Sendable @MainActor (Diagnostics) -> Void) {
291
+ Task {
292
+ let result = await diagnostics()
293
+ await MainActor.run { completion(result) }
294
+ }
295
+ }
296
+
297
+ // MARK: - Internals
298
+
299
+ /// https only; http for local development (localhost, the Simulator's host, *.test).
300
+ static func isAllowedBaseUrl(_ url: String) -> Bool {
301
+ guard let components = URLComponents(string: url), let host = components.host?.lowercased(), !host.isEmpty else { return false }
302
+ switch components.scheme?.lowercased() {
303
+ case "https": return true
304
+ case "http": return host == "localhost" || host == "127.0.0.1" || host == "10.0.2.2" || host.hasSuffix(".test")
305
+ default: return false
306
+ }
307
+ }
308
+ }
@@ -0,0 +1,118 @@
1
+ import Foundation
2
+ import os
3
+ #if canImport(UserNotifications)
4
+ import UserNotifications
5
+ #endif
6
+ #if !COCOAPODS
7
+ import BubblCore
8
+ #endif
9
+
10
+ /// What `Bubbl`'s calls do: the engine on iOS 17 and later (`SupportedBubbl`), nothing below it
11
+ /// (`UnsupportedBubbl`). Apps supporting older iOS versions can install Bubbl; it just does nothing
12
+ /// on those phones. The engine and everything it uses are `@available(iOS 17, *)`, so the compiler
13
+ /// proves that the one `#available` check in `Bubbl.backend` is the only way in.
14
+ protocol BubblBackend: Sendable {
15
+ var isSupported: Bool { get }
16
+
17
+ func start(apiKey: String, options: BubblOptions)
18
+ func start(credential: BubblCredential, options: BubblOptions)
19
+ func stop()
20
+
21
+ func setConsent(_ granted: Bool)
22
+ func optOut()
23
+ func deleteMyData()
24
+ func setLocationEnabled(_ enabled: Bool)
25
+ func registerTestDevice(_ code: String) async -> BubblTestDeviceResult
26
+
27
+ func setSegments(_ segments: [String])
28
+ func track(_ name: String, properties: [String: Any?])
29
+
30
+ func setNotificationListener(_ listener: (@MainActor @Sendable (BubblMessage) -> Bool)?)
31
+ func addEventListener(_ listener: @escaping @MainActor @Sendable (BubblEvent) -> Void) -> BubblEventToken
32
+ func removeEventListener(_ token: BubblEventToken)
33
+ func present(_ message: BubblMessage)
34
+ func report(_ call: String, _ record: @escaping @Sendable (NotificationEvents) async throws -> Void)
35
+ func submitSurvey(_ message: BubblMessage, answers: [String: Any?]) -> Bool
36
+ func openCta(_ message: BubblMessage)
37
+
38
+ func isBubblMessage(_ userInfo: [AnyHashable: Any]) -> Bool
39
+ #if os(iOS)
40
+ func setPushToken(_ deviceToken: Data)
41
+ func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool
42
+ func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool
43
+ #endif
44
+
45
+ func permissionStatus() -> BubblPermissionStatus?
46
+ func requestPermission(_ request: PermissionRequest, _ call: String) async -> BubblPermissionStatus?
47
+ func openSettings()
48
+
49
+ func diagnostics() async -> Bubbl.Diagnostics
50
+ }
51
+
52
+ extension Bubbl {
53
+ /// The one way into the engine.
54
+ static let backend: any BubblBackend = {
55
+ if #available(iOS 17, *) { return SupportedBubbl() }
56
+ return UnsupportedBubbl()
57
+ }()
58
+ }
59
+
60
+ /// Below iOS 17: every call returns at once and does nothing. No install, no network, no prompts,
61
+ /// no notification delegate, no background tasks, no location. `start` says so in the log, once
62
+ /// per call; permission calls report nil (as before `start`) and never prompt; pushes and taps are
63
+ /// left to the app.
64
+ struct UnsupportedBubbl: BubblBackend {
65
+ var isSupported: Bool { false }
66
+
67
+ func start(apiKey: String, options: BubblOptions) { said(options) }
68
+ func start(credential: BubblCredential, options: BubblOptions) { said(options) }
69
+ func stop() {}
70
+
71
+ func setConsent(_ granted: Bool) {}
72
+ func optOut() {}
73
+ func deleteMyData() {}
74
+ func setLocationEnabled(_ enabled: Bool) {}
75
+ func registerTestDevice(_ code: String) async -> BubblTestDeviceResult {
76
+ BubblTestDeviceResult(approved: false, message: "Bubbl needs iOS 17 or later")
77
+ }
78
+
79
+ func setSegments(_ segments: [String]) {}
80
+ func track(_ name: String, properties: [String: Any?]) {}
81
+
82
+ func setNotificationListener(_ listener: (@MainActor @Sendable (BubblMessage) -> Bool)?) {}
83
+ func addEventListener(_ listener: @escaping @MainActor @Sendable (BubblEvent) -> Void) -> BubblEventToken {
84
+ BubblEventToken(id: UUID())
85
+ }
86
+ func removeEventListener(_ token: BubblEventToken) {}
87
+ func present(_ message: BubblMessage) {}
88
+ func report(_ call: String, _ record: @escaping @Sendable (NotificationEvents) async throws -> Void) {}
89
+ func submitSurvey(_ message: BubblMessage, answers: [String: Any?]) -> Bool { false }
90
+ func openCta(_ message: BubblMessage) {}
91
+
92
+ func isBubblMessage(_ userInfo: [AnyHashable: Any]) -> Bool { false }
93
+ #if os(iOS)
94
+ func setPushToken(_ deviceToken: Data) {}
95
+ func willPresent(_ notification: UNNotification, completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) -> Bool { false }
96
+ func didReceive(_ response: UNNotificationResponse, completionHandler: @escaping () -> Void) -> Bool { false }
97
+ #endif
98
+
99
+ func permissionStatus() -> BubblPermissionStatus? { nil }
100
+ func requestPermission(_ request: PermissionRequest, _ call: String) async -> BubblPermissionStatus? { nil }
101
+ func openSettings() {}
102
+
103
+ func diagnostics() async -> Bubbl.Diagnostics {
104
+ Bubbl.Diagnostics(
105
+ started: false, supported: false, sdkVersion: Bubbl.sdkVersion, sdkSupported: true, registered: false, installId: nil,
106
+ active: false, pausedUntil: nil, consentRequired: false, consent: nil, locationEnabled: true, permissions: nil,
107
+ hasPushToken: false, geofences: 0, queuedEvents: 0, lastError: nil, credentialRejected: false, backgroundLocation: false,
108
+ transitionsDroppedWhileLocked: 0, backgroundRefreshAvailable: false, lowPowerMode: ProcessInfo.processInfo.isLowPowerModeEnabled,
109
+ osWatchesGeofences: false, environment: nil, testDevice: nil
110
+ )
111
+ }
112
+
113
+ /// The one line `start` writes: os_log, since the SDK's own log (os.Logger) is iOS 14 and later.
114
+ private func said(_ options: BubblOptions) {
115
+ guard options.logLevel != .none else { return }
116
+ os_log("Bubbl %{public}@ needs iOS 17 or later: it does nothing on this device", log: OSLog(subsystem: "tech.bubbl.sdk", category: "Bubbl"), type: .info, Bubbl.sdkVersion)
117
+ }
118
+ }
@@ -0,0 +1,30 @@
1
+ import Foundation
2
+ #if !COCOAPODS
3
+ import BubblCore
4
+ #endif
5
+
6
+ /// A device credential issued outside the app (installs provisioned ahead of time, an app's own
7
+ /// pairing flow), for `Bubbl.start(credential:options:)` instead of an API key. The same as
8
+ /// Android's BubblCredential. Its secret is kept in the Keychain and never shown: not in
9
+ /// `description`, not by `dump` or the debugger.
10
+ public struct BubblCredential: Sendable, Equatable, CustomStringConvertible, CustomDebugStringConvertible, CustomReflectable {
11
+ public let keyId: String
12
+ public let secret: String
13
+ /// The install the credential was issued for (a pairing flow passes the install_id it paired
14
+ /// with).
15
+ public let installId: String
16
+
17
+ public init(keyId: String, secret: String, installId: String) {
18
+ self.keyId = keyId
19
+ self.secret = secret
20
+ self.installId = installId
21
+ }
22
+
23
+ public var description: String { "BubblCredential(keyId: \(keyId), installId: \(installId), secret: hidden)" }
24
+ public var debugDescription: String { description }
25
+ public var customMirror: Mirror {
26
+ Mirror(self, children: ["keyId": keyId, "installId": installId, "secret": "hidden"], displayStyle: .struct)
27
+ }
28
+
29
+ var issued: IssuedCredential { IssuedCredential(keyId: keyId, secret: secret, installId: installId) }
30
+ }
@@ -0,0 +1,151 @@
1
+ import Foundation
2
+ #if !COCOAPODS
3
+ import BubblCore
4
+ #endif
5
+
6
+ /// A notification from Bubbl, as an app's own handler sees it (`Bubbl.setNotificationListener`):
7
+ /// enough to draw it itself, or to hand back to `Bubbl.present` to show Bubbl's screen. The same
8
+ /// as Android's BubblMessage.
9
+ public struct BubblMessage: Sendable, Hashable, CustomStringConvertible {
10
+ let notification: BubblNotification
11
+
12
+ init(_ notification: BubblNotification) {
13
+ self.notification = notification
14
+ }
15
+
16
+ /// A message back from its `json` (kept for an in-app inbox, say, then shown again with
17
+ /// `Bubbl.present`); nil when it isn't one.
18
+ public init?(json: String) {
19
+ guard let notification = BubblNotification(json: json) else { return nil }
20
+ self.init(notification)
21
+ }
22
+
23
+ /// Echo this on anything the app reports about it.
24
+ public var id: String { notification.campaignNotificationId }
25
+ public var headline: String { notification.headline }
26
+ public var body: String { notification.body }
27
+ public var isSurvey: Bool { notification.isSurvey }
28
+ /// From a Sandbox workspace: an app drawing it itself should mark it as such, as Bubbl's
29
+ /// screen does with its SANDBOX ribbon.
30
+ public var isSandbox: Bool { notification.sandbox }
31
+
32
+ /// image, video, audio, youtube, application, text or file; nil without media.
33
+ public var mediaType: String? { notification.media?.kind.rawValue }
34
+ public var mediaUrl: String? { notification.media?.url }
35
+ public var mediaThumbnailUrl: String? { notification.media?.pictureUrl }
36
+
37
+ public var ctaLabel: String? { notification.cta?.label }
38
+ public var ctaUrl: String? { notification.cta?.url }
39
+
40
+ /// A survey's questions in order (empty for a message), for an app drawing it itself.
41
+ public var questions: [BubblQuestion] { notification.questions.map(BubblQuestion.init) }
42
+
43
+ /// The whole notification as the device API sent it (JSON), e.g. to pass to a wrapper.
44
+ public var json: String { notification.jsonText }
45
+
46
+ public var description: String { "BubblMessage(\(id), \"\(headline)\")" }
47
+ }
48
+
49
+ /// One question of a survey, and what `Bubbl.submitSurvey` takes as its answer, by `type`:
50
+ /// single choice a choice id; multiple choice a list of choice ids; rating a whole number 1–5;
51
+ /// boolean true/false; number a number; slider a number from 0 to 10; open-ended text (≤ 2000).
52
+ public struct BubblQuestion: Sendable, Hashable {
53
+ /// Android's BubblQuestion.Type (`Type` is taken in Swift).
54
+ public enum Kind: String, Sendable, CaseIterable {
55
+ case openEnded = "open_ended"
56
+ case singleChoice = "single_choice"
57
+ case multipleChoice = "multiple_choice"
58
+ case rating, boolean, number, slider
59
+ }
60
+
61
+ public struct Choice: Sendable, Hashable {
62
+ public let id: String
63
+ public let text: String
64
+ }
65
+
66
+ public let id: String
67
+ public let text: String
68
+ public let type: Kind
69
+ public let required: Bool
70
+ public let choices: [Choice]
71
+
72
+ init(_ question: BubblNotification.Question) {
73
+ id = question.id
74
+ text = question.text
75
+ type = Kind(rawValue: question.kind.rawValue) ?? .openEnded
76
+ required = question.required
77
+ choices = question.choices.map { Choice(id: $0.id, text: $0.text) }
78
+ }
79
+ }
80
+
81
+ /// What Bubbl does, as it happens, for an app that wants to know (`Bubbl.addEventListener`): each
82
+ /// step of a notification's life, geofences entered and left, and errors. Delivered on the main
83
+ /// thread. They're for the app's own use (its analytics, its UI); Bubbl records its own.
84
+ ///
85
+ /// New cases arrive in minor versions, so switch over it with a `default:` branch.
86
+ public enum BubblEvent: Sendable, Hashable {
87
+ /// A notification arrived (from a geofence, a push or a pull), before it's shown.
88
+ case notificationReceived(BubblMessage)
89
+ case notificationDisplayed(BubblMessage)
90
+ /// Opened from its system notification.
91
+ case notificationOpened(BubblMessage)
92
+ case notificationCtaClicked(BubblMessage)
93
+ case notificationDismissed(BubblMessage)
94
+ case mediaViewed(BubblMessage)
95
+ case mediaCompleted(BubblMessage)
96
+ case surveyStarted(BubblMessage)
97
+ case surveySubmitted(BubblMessage)
98
+ /// The device entered one of the workspace's locations.
99
+ case geofenceEntered(locationId: String)
100
+ case geofenceExited(locationId: String)
101
+ /// Something went wrong that Bubbl couldn't fix itself (also in diagnostics' lastError).
102
+ case error(message: String)
103
+ /// The server refused the credential Bubbl was started with (`Bubbl.start(credential:options:)`):
104
+ /// Bubbl has stopped until it's started with a new one.
105
+ case credentialRejected
106
+ }
107
+
108
+ /// What `Bubbl.addEventListener` returns, to remove that listener with (Swift closures have no
109
+ /// identity to remove them by).
110
+ public struct BubblEventToken: Hashable, Sendable {
111
+ let id: UUID
112
+ }
113
+
114
+ /// The app's event listeners, each told of every event on the main thread.
115
+ final class BubblEvents: @unchecked Sendable {
116
+ static let shared = BubblEvents()
117
+
118
+ private let lock = NSLock()
119
+ private var listeners: [UUID: @MainActor @Sendable (BubblEvent) -> Void] = [:]
120
+
121
+ func add(_ listener: @escaping @MainActor @Sendable (BubblEvent) -> Void) -> BubblEventToken {
122
+ let id = UUID()
123
+ lock.sync { listeners[id] = listener }
124
+ return BubblEventToken(id: id)
125
+ }
126
+
127
+ func remove(_ token: BubblEventToken) {
128
+ lock.sync { _ = listeners.removeValue(forKey: token.id) }
129
+ }
130
+
131
+ func emit(_ event: BubblEvent) {
132
+ let current = lock.sync { Array(listeners.values) }
133
+ guard !current.isEmpty else { return }
134
+ Task { @MainActor in current.forEach { $0(event) } }
135
+ }
136
+
137
+ /// The event for a notification event the engine recorded (NotificationEvents' types).
138
+ static func event(_ type: String, _ message: BubblMessage) -> BubblEvent? {
139
+ switch type {
140
+ case "notification.displayed": .notificationDisplayed(message)
141
+ case "notification.opened": .notificationOpened(message)
142
+ case "notification.cta_clicked": .notificationCtaClicked(message)
143
+ case "notification.dismissed": .notificationDismissed(message)
144
+ case "media.viewed": .mediaViewed(message)
145
+ case "media.completed": .mediaCompleted(message)
146
+ case "notification.survey_started": .surveyStarted(message)
147
+ case "survey.submitted": .surveySubmitted(message)
148
+ default: nil
149
+ }
150
+ }
151
+ }
@@ -0,0 +1,39 @@
1
+ import Foundation
2
+ #if !COCOAPODS
3
+ import BubblCore
4
+ #endif
5
+
6
+ /// How `Bubbl.start` sets Bubbl up. The same options as Android's BubblOptions.
7
+ public struct BubblOptions: Sendable, Equatable {
8
+ /// How much Bubbl writes to the system log (subsystem "tech.bubbl.sdk").
9
+ public enum LogLevel: String, Sendable, Equatable, CaseIterable, Codable {
10
+ case none, error, warning, info, debug
11
+
12
+ var core: LogSeverity? {
13
+ switch self {
14
+ case .none: nil
15
+ case .error: .error
16
+ case .warning: .warning
17
+ case .info: .info
18
+ case .debug: .debug
19
+ }
20
+ }
21
+ }
22
+
23
+ /// The Bubbl API to talk to (https). Required for now: 5.0 pre-releases have no default until
24
+ /// the production host is settled.
25
+ public var baseUrl: String
26
+ /// True when the app asks its users first: Bubbl does nothing (no network, no location, no
27
+ /// notifications) until `Bubbl.setConsent(true)`.
28
+ public var requireConsent: Bool
29
+ public var logLevel: LogLevel
30
+ /// The device's segments, as `Bubbl.setSegments` would set them.
31
+ public var segments: [String]?
32
+
33
+ public init(baseUrl: String, requireConsent: Bool = false, logLevel: LogLevel = .warning, segments: [String]? = nil) {
34
+ self.baseUrl = baseUrl
35
+ self.requireConsent = requireConsent
36
+ self.logLevel = logLevel
37
+ self.segments = segments
38
+ }
39
+ }