@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,137 @@
1
+ import Foundation
2
+
3
+ /// A notification as the device API returns it (the contract's Notification: from a geofence
4
+ /// event, a push, a pull or a fetch). `json` is kept whole: it's what an app's own handler is
5
+ /// given, and what the notification screen and the wrappers are handed.
6
+ ///
7
+ /// Read leniently, as on Android: media of an unknown type and questions of an unknown type are
8
+ /// left out (a newer server), and only a missing id makes it no notification at all.
9
+ package struct BubblNotification: Sendable, Hashable {
10
+ package let json: JSONValue
11
+ package let campaignNotificationId: String
12
+ package let locationId: String?
13
+ package let isSurvey: Bool
14
+ package let headline: String
15
+ package let body: String
16
+ package let media: Media?
17
+ package let cta: Cta?
18
+ package let questions: [Question]
19
+ /// From a Sandbox workspace: Bubbl's screen shows a SANDBOX ribbon on it.
20
+ package let sandbox: Bool
21
+
22
+ package struct Cta: Sendable, Hashable {
23
+ package let label: String
24
+ package let url: String
25
+
26
+ package init(label: String, url: String) {
27
+ self.label = label
28
+ self.url = url
29
+ }
30
+ }
31
+
32
+ package struct Media: Sendable, Hashable {
33
+ package enum Kind: String, Sendable, CaseIterable {
34
+ case image, video, audio, youtube, application, text, file
35
+ }
36
+
37
+ package let kind: Kind
38
+ package let url: String?
39
+ package let mimeType: String?
40
+ package let thumbnailUrl: String?
41
+
42
+ /// The picture to show for it: the image itself, or a video's thumbnail.
43
+ package var pictureUrl: String? { kind == .image ? url ?? thumbnailUrl : thumbnailUrl }
44
+ }
45
+
46
+ package struct Choice: Sendable, Hashable {
47
+ package let id: String
48
+ package let text: String
49
+ }
50
+
51
+ package struct Question: Sendable, Hashable {
52
+ package enum Kind: String, Sendable, CaseIterable {
53
+ case openEnded = "open_ended"
54
+ case singleChoice = "single_choice"
55
+ case multipleChoice = "multiple_choice"
56
+ case rating, boolean, number, slider
57
+ }
58
+
59
+ package let id: String
60
+ package let text: String
61
+ package let kind: Kind
62
+ package let required: Bool
63
+ package let choices: [Choice]
64
+ }
65
+
66
+ /// Nil when `json` isn't a notification this SDK can show (no id).
67
+ package init?(_ json: JSONValue) {
68
+ guard let id = json["campaign_notification_id"]?.nonEmptyString else {
69
+ BubblLog.warning("A notification without a campaign_notification_id was left out")
70
+ return nil
71
+ }
72
+ self.json = json
73
+ campaignNotificationId = id
74
+ locationId = json["location_id"]?.nonEmptyString
75
+ isSurvey = json["type"]?.stringValue == "survey"
76
+ headline = json["headline"]?.stringValue ?? ""
77
+ body = json["body"]?.stringValue ?? ""
78
+ sandbox = json["sandbox"] == .bool(true)
79
+
80
+ if let media = json["media"], media != .null {
81
+ let type = media["type"]?.stringValue ?? ""
82
+ if let kind = Media.Kind(rawValue: type.lowercased()) {
83
+ self.media = Media(kind: kind, url: media["url"]?.nonEmptyString, mimeType: media["mime_type"]?.nonEmptyString, thumbnailUrl: media["thumbnail_url"]?.nonEmptyString)
84
+ } else {
85
+ BubblLog.warning("Media of unknown type \"\(type)\" left out of notification \(id) (a newer SDK shows it)")
86
+ self.media = nil
87
+ }
88
+ } else {
89
+ media = nil
90
+ }
91
+
92
+ if let url = json["cta"]?["url"]?.nonEmptyString {
93
+ cta = Cta(label: json["cta"]?["label"]?.nonEmptyString ?? url, url: url)
94
+ } else {
95
+ cta = nil
96
+ }
97
+
98
+ if case .array(let items)? = json["survey"]?["questions"] {
99
+ questions = items.compactMap { item in
100
+ let type = item["type"]?.stringValue ?? ""
101
+ guard let questionId = item["id"]?.nonEmptyString, let kind = Question.Kind(rawValue: type.lowercased()) else {
102
+ BubblLog.warning("A question of type \"\(type)\" left out of notification \(id) (a newer SDK asks it)")
103
+ return nil
104
+ }
105
+ var choices: [Choice] = []
106
+ if case .array(let options)? = item["choices"] {
107
+ choices = options.compactMap { option in
108
+ option["id"]?.nonEmptyString.map { Choice(id: $0, text: option["text"]?.stringValue ?? "") }
109
+ }
110
+ }
111
+ let required = if case .bool(let value)? = item["required"] { value } else { false }
112
+ return Question(id: questionId, text: item["text"]?.stringValue ?? "", kind: kind, required: required, choices: choices)
113
+ }
114
+ } else {
115
+ questions = []
116
+ }
117
+ }
118
+
119
+ /// From the notification's JSON text; nil when it isn't one.
120
+ package init?(json text: String) {
121
+ guard let value = try? JSONDecoder().decode(JSONValue.self, from: Data(text.utf8)) else { return nil }
122
+ self.init(value)
123
+ }
124
+
125
+ /// The JSON text, as the device API sent it (key order aside).
126
+ package var jsonText: String {
127
+ (try? JSONEncoder().encode(json)).map { String(decoding: $0, as: UTF8.self) } ?? "{}"
128
+ }
129
+ }
130
+
131
+ extension JSONValue {
132
+ /// A string that isn't empty; nil for anything else.
133
+ package var nonEmptyString: String? {
134
+ guard case .string(let value) = self, !value.isEmpty else { return nil }
135
+ return value
136
+ }
137
+ }
@@ -0,0 +1,160 @@
1
+ import Foundation
2
+
3
+ /// The events a notification's life produces, in the contract's shapes (events.json), handed to
4
+ /// `enqueue` (the event queue). Each carries the campaign_notification_id, as the contract asks.
5
+ package struct NotificationEvents: Sendable {
6
+ private let observe: @Sendable (_ type: String, _ notification: BubblNotification) -> Void
7
+ private let enqueue: @Sendable (_ type: String, _ data: [String: JSONValue]) async throws -> Void
8
+
9
+ /// - Parameter observe: told of each event as it's recorded (the app's event listeners).
10
+ package init(
11
+ observe: @escaping @Sendable (String, BubblNotification) -> Void = { _, _ in },
12
+ enqueue: @escaping @Sendable (String, [String: JSONValue]) async throws -> Void
13
+ ) {
14
+ self.observe = observe
15
+ self.enqueue = enqueue
16
+ }
17
+
18
+ package func displayed(_ n: BubblNotification) async throws {
19
+ var data = base(n)
20
+ if let location = n.locationId { data["location_id"] = .string(location) }
21
+ try await record("notification.displayed", n, data)
22
+ }
23
+
24
+ package func opened(_ n: BubblNotification) async throws { try await record("notification.opened", n, base(n)) }
25
+ package func dismissed(_ n: BubblNotification) async throws { try await record("notification.dismissed", n, base(n)) }
26
+ package func ctaClicked(_ n: BubblNotification) async throws { try await record("notification.cta_clicked", n, base(n)) }
27
+ package func surveyStarted(_ n: BubblNotification) async throws { try await record("notification.survey_started", n, base(n)) }
28
+
29
+ /// Throws SurveyProblem when the form isn't complete: nothing is recorded.
30
+ package func surveySubmitted(_ n: BubblNotification, _ form: SurveyForm) async throws {
31
+ guard let data = form.eventData(n.campaignNotificationId) else {
32
+ throw SurveyProblem("\"\(form.missing.first?.text ?? "")\" needs an answer")
33
+ }
34
+ try await record("survey.submitted", n, data)
35
+ }
36
+
37
+ package func mediaViewed(_ n: BubblNotification, positionSeconds: Double) async throws {
38
+ var data = base(n)
39
+ data["position_seconds"] = .double(positionSeconds)
40
+ try await record("media.viewed", n, data)
41
+ }
42
+
43
+ package func mediaCompleted(_ n: BubblNotification, positionSeconds: Double) async throws {
44
+ var data = base(n)
45
+ data["position_seconds"] = .double(positionSeconds)
46
+ try await record("media.completed", n, data)
47
+ }
48
+
49
+ private func record(_ type: String, _ n: BubblNotification, _ data: [String: JSONValue]) async throws {
50
+ try await enqueue(type, data)
51
+ observe(type, n)
52
+ }
53
+
54
+ private func base(_ n: BubblNotification) -> [String: JSONValue] {
55
+ ["campaign_notification_id": .string(n.campaignNotificationId)]
56
+ }
57
+ }
58
+
59
+ /// The notifications shown lately, so one that arrives twice (a push and then a pull, a push
60
+ /// retried) is only shown once. Keeps the most recent `capacity`.
61
+ package final class RecentNotifications: Sendable {
62
+ private let store: any ValueStore<[String]>
63
+ private let capacity: Int
64
+ private let lock = NSLock()
65
+
66
+ package init(store: any ValueStore<[String]>, capacity: Int = 200) {
67
+ self.store = store
68
+ self.capacity = capacity
69
+ }
70
+
71
+ /// True the first time `campaignNotificationId` is seen; false after that. When the list can't
72
+ /// be read (before the first unlock) it's taken as the first time: showing a notification
73
+ /// twice beats losing it.
74
+ package func firstTime(_ campaignNotificationId: String) -> Bool {
75
+ lock.sync {
76
+ guard let ids = try? store.load() ?? [] else { return true }
77
+ if ids.contains(campaignNotificationId) { return false }
78
+ try? store.save(Array((ids + [campaignNotificationId]).suffix(capacity)))
79
+ return true
80
+ }
81
+ }
82
+ }
83
+
84
+ /// Which links from a campaign (a CTA, a media link) the SDK opens: web links and app links,
85
+ /// never schemes that reach into the device or run code.
86
+ package enum LinkPolicy {
87
+ /// Android's, and iOS's enterprise app-install scheme.
88
+ private static let blocked: Set<String> = ["javascript", "file", "content", "intent", "data", "android-app", "about", "itms-services"]
89
+
90
+ package static func canOpen(_ url: String) -> Bool {
91
+ guard let scheme = scheme(of: url.trimmingCharacters(in: .whitespaces)) else { return false }
92
+ return !blocked.contains(scheme)
93
+ }
94
+
95
+ /// The URL's scheme (RFC 3986: a letter, then letters, digits, + - .), lower-cased; nil when
96
+ /// it has none. Read here rather than by URL(string:), whose leniency differs by platform.
97
+ private static func scheme(of url: String) -> String? {
98
+ guard let colon = url.firstIndex(of: ":") else { return nil }
99
+ let scheme = url[..<colon]
100
+ guard let first = scheme.unicodeScalars.first, first.isASCII, CharacterSet.letters.contains(first),
101
+ scheme.unicodeScalars.allSatisfy({ $0.isASCII && (CharacterSet.alphanumerics.contains($0) || "+-.".unicodeScalars.contains($0)) }),
102
+ !url[url.index(after: colon)...].contains(" ")
103
+ else { return nil }
104
+ return scheme.lowercased()
105
+ }
106
+ }
107
+
108
+ /// Embedding a YouTube video in the notification screen.
109
+ package enum YouTube {
110
+ private static let hosts: Set<String> = ["youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com"]
111
+
112
+ /// The video id in a YouTube link (watch?v=, youtu.be/, /embed/, /shorts/, /live/), or nil when
113
+ /// it isn't one. Only a well-formed id comes back, since it goes into the embed page.
114
+ package static func videoId(_ url: String) -> String? {
115
+ guard let components = URLComponents(string: url.trimmingCharacters(in: .whitespaces)),
116
+ let scheme = components.scheme?.lowercased(), scheme == "http" || scheme == "https",
117
+ let host = components.host?.lowercased()
118
+ else { return nil }
119
+ let segments = components.path.split(separator: "/").map(String.init)
120
+
121
+ let candidate: String?
122
+ if host == "youtu.be" {
123
+ candidate = segments.first
124
+ } else if hosts.contains(host), segments.first == "watch" {
125
+ candidate = components.queryItems?.first { $0.name == "v" }?.value
126
+ } else if hosts.contains(host), let first = segments.first, ["embed", "shorts", "live", "v"].contains(first), segments.count > 1 {
127
+ candidate = segments[1]
128
+ } else {
129
+ candidate = nil
130
+ }
131
+ return candidate.flatMap { isVideoId($0) ? $0 : nil }
132
+ }
133
+
134
+ /// The page the embedded player lives in: YouTube's privacy-enhanced player (no cookies until
135
+ /// the video plays) filling the frame, starting at once since the person has just tapped Play.
136
+ /// `origin` identifies the app to YouTube, which refuses embeds that don't say where they are
137
+ /// (load the page with that origin as its base URL). Nil when `videoId` isn't one.
138
+ package static func embedHtml(videoId: String, origin: String) -> String? {
139
+ guard isVideoId(videoId) else { return nil }
140
+ // RFC 3986 unreserved characters stay as they are; everything else is escaped.
141
+ let unreserved = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))
142
+ let encodedOrigin = origin.addingPercentEncoding(withAllowedCharacters: unreserved) ?? ""
143
+ let src = "https://www.youtube-nocookie.com/embed/\(videoId)?autoplay=1&playsinline=1&rel=0&origin=\(encodedOrigin)"
144
+ return """
145
+ <!doctype html><html><head>
146
+ <meta name="viewport" content="width=device-width,initial-scale=1">
147
+ <style>html,body{margin:0;height:100%;background:#000;overflow:hidden}iframe{position:absolute;inset:0;width:100%;height:100%;border:0}</style>
148
+ </head><body>
149
+ <iframe src="\(src)" allow="autoplay; encrypted-media; picture-in-picture; fullscreen" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>
150
+ </body></html>
151
+ """
152
+ }
153
+
154
+ /// Eleven of A–Z a–z 0–9 _ -.
155
+ private static func isVideoId(_ value: String) -> Bool {
156
+ value.unicodeScalars.count == 11 && value.unicodeScalars.allSatisfy {
157
+ $0.isASCII && (CharacterSet.alphanumerics.contains($0) || $0 == "_" || $0 == "-")
158
+ }
159
+ }
160
+ }
@@ -0,0 +1,134 @@
1
+ import Foundation
2
+
3
+ /// A push's data, read as the contract's push.json describes it. Only pushes marked bubbl=1 are
4
+ /// Bubbl's; everything else is the app's own and is left alone. On iOS the data keys sit beside
5
+ /// `aps` in the payload (FCM-relayed pushes too); the iOS layer turns the payload into
6
+ /// JSONValue once and hands its top level here.
7
+ package enum PushMessage: Sendable, Equatable {
8
+ /// The whole notification came in the push.
9
+ case full(campaignNotificationId: String, notification: JSONValue)
10
+ /// Too big for the push: fetch it with GET /notifications/{id}.
11
+ case reference(campaignNotificationId: String)
12
+ /// A dashboard test push: no campaign behind it, so nothing to fetch or report.
13
+ case test(title: String, body: String)
14
+ /// Bubbl's, but not something this SDK can show (a newer format): not the app's to handle either.
15
+ case unsupported(version: String)
16
+
17
+ package static let version = "1"
18
+ /// The data keys a Bubbl push carries (push.json).
19
+ package static let keys = ["bubbl", "bubbl_v", "campaign_notification_id", "notification", "test", "title", "body", "image_url"]
20
+
21
+ /// The picture for the system notification (image_url), for the notification service
22
+ /// extension: there even when the notification itself was too big to come in the push. Only
23
+ /// https, and only in Bubbl's pushes of a format this SDK reads.
24
+ package static func imageUrl(_ data: [String: JSONValue]) -> String? {
25
+ guard isBubbl(data), (data["bubbl_v"].flatMap(text) ?? version) == version,
26
+ let url = data["image_url"]?.nonEmptyString, url.lowercased().hasPrefix("https://")
27
+ else { return nil }
28
+ return url
29
+ }
30
+
31
+ /// The contract's push data is all strings (FCM's data is, and the APNs sender puts the same
32
+ /// string map beside aps). Flags are also taken as 1 or true, so a backend change can't make
33
+ /// pushes vanish silently.
34
+ package static func isBubbl(_ data: [String: JSONValue]) -> Bool {
35
+ isSet(data["bubbl"])
36
+ }
37
+
38
+ /// Nil when the push isn't Bubbl's.
39
+ package static func parse(_ data: [String: JSONValue]) -> PushMessage? {
40
+ guard isBubbl(data) else { return nil }
41
+ let version = data["bubbl_v"].flatMap(text) ?? Self.version
42
+ guard version == Self.version else {
43
+ BubblLog.warning("A push in data format \(version) was left alone: this SDK reads format \(Self.version)")
44
+ return .unsupported(version: version)
45
+ }
46
+ if isSet(data["test"]) {
47
+ return .test(title: data["title"]?.stringValue ?? "", body: data["body"]?.stringValue ?? "")
48
+ }
49
+
50
+ guard let id = data["campaign_notification_id"]?.nonEmptyString else {
51
+ BubblLog.warning("A Bubbl push with no campaign_notification_id was left alone")
52
+ return .unsupported(version: version)
53
+ }
54
+ // A notification that doesn't parse is fetched instead, rather than lost.
55
+ switch data["notification"] {
56
+ case .object?:
57
+ return .full(campaignNotificationId: id, notification: data["notification"] ?? .null)
58
+ case .string(let text)?:
59
+ if let notification = try? JSONDecoder().decode(JSONValue.self, from: Data(text.utf8)), case .object = notification {
60
+ return .full(campaignNotificationId: id, notification: notification)
61
+ }
62
+ return .reference(campaignNotificationId: id)
63
+ default:
64
+ return .reference(campaignNotificationId: id)
65
+ }
66
+ }
67
+
68
+ /// "1", 1 or true.
69
+ private static func isSet(_ value: JSONValue?) -> Bool {
70
+ switch value {
71
+ case .string("1")?, .int(1)?, .bool(true)?: true
72
+ default: false
73
+ }
74
+ }
75
+
76
+ /// A string, or a whole number as one ("1" or 1).
77
+ private static func text(_ value: JSONValue) -> String? {
78
+ switch value {
79
+ case .string(let text): text
80
+ case .int(let number): String(number)
81
+ default: nil
82
+ }
83
+ }
84
+ }
85
+
86
+ package enum NotificationsResult: Sendable, Equatable {
87
+ case found([JSONValue])
88
+ /// The notification no longer exists (deleted, or the campaign ended).
89
+ case gone
90
+ case failed(ApiFailure)
91
+ }
92
+
93
+ /// GET /notifications/{id} and POST /notifications/pull.
94
+ package struct NotificationSource: Sendable {
95
+ private let api: DeviceApiClient
96
+
97
+ package init(api: DeviceApiClient) {
98
+ self.api = api
99
+ }
100
+
101
+ /// One notification a push referred to without carrying it.
102
+ package func fetch(_ campaignNotificationId: String) async -> NotificationsResult {
103
+ let response: ApiResponse
104
+ do {
105
+ let id = campaignNotificationId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(["/"])) ?? campaignNotificationId
106
+ response = try await api.request("GET", "api/v1/notifications/\(id)")
107
+ } catch {
108
+ return .failed(.backoff)
109
+ }
110
+
111
+ struct One: Decodable { let data: JSONValue }
112
+ if (200...299).contains(response.status) {
113
+ guard let one = response.decode(One.self), case .object = one.data else { return .failed(.backoff) }
114
+ return .found([one.data])
115
+ }
116
+ if response.status == 404 { return .gone }
117
+ return .failed(ApiFailure.of(response))
118
+ }
119
+
120
+ /// Scheduled pushes this device hasn't had (a safety net for pushes the OS dropped). Each is
121
+ /// claimed by the call, so a notification comes back from here at most once.
122
+ package func pull() async -> NotificationsResult {
123
+ let response: ApiResponse
124
+ do {
125
+ response = try await api.request("POST", "api/v1/notifications/pull")
126
+ } catch {
127
+ return .failed(.backoff)
128
+ }
129
+
130
+ struct Many: Decodable { let data: [JSONValue]? }
131
+ guard (200...299).contains(response.status) else { return .failed(ApiFailure.of(response)) }
132
+ return .found((response.decode(Many.self)?.data ?? []).filter { if case .object = $0 { true } else { false } })
133
+ }
134
+ }
@@ -0,0 +1,185 @@
1
+ import Foundation
2
+
3
+ /// The answers to a survey as they're given, checked by the same rules the server applies to
4
+ /// survey.submitted (DeviceEvents::answer), as on Android: choices by id, one for a single
5
+ /// choice; a rating a whole number from 1 to 5; true/false; a number; text of at most 2000
6
+ /// characters. Every required question needs an answer before it can be sent.
7
+ package struct SurveyForm: Sendable {
8
+ package typealias Question = BubblNotification.Question
9
+
10
+ package static let maxText = 2000
11
+ /// The contract leaves the slider's range open; 0–10 in halves, like a recommend score.
12
+ package static let sliderMin = 0.0
13
+ package static let sliderMax = 10.0
14
+ package static let sliderStep = 0.5
15
+
16
+ package let questions: [Question]
17
+ private var answers: [String: JSONValue] = [:]
18
+
19
+ package init(_ questions: [Question]) {
20
+ self.questions = questions
21
+ }
22
+
23
+ package func answer(_ questionId: String) -> JSONValue? { answers[questionId] }
24
+
25
+ package mutating func setText(_ question: Question, _ text: String) {
26
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
27
+ set(question, trimmed.isEmpty ? nil : .string(String(String.UnicodeScalarView(trimmed.unicodeScalars.prefix(Self.maxText)))))
28
+ }
29
+
30
+ package mutating func setChoice(_ question: Question, _ choiceId: String) {
31
+ set(question, .string(choiceId))
32
+ }
33
+
34
+ package mutating func toggleChoice(_ question: Question, _ choiceId: String, chosen: Bool) {
35
+ var current: [String] = []
36
+ if case .array(let values)? = answers[question.id] { current = values.compactMap(\.stringValue) }
37
+ let next = chosen ? current + [choiceId] : current.filter { $0 != choiceId }
38
+ // Keep the question's own order, whatever order they were ticked in.
39
+ let ordered = question.choices.map(\.id).filter(next.contains)
40
+ set(question, ordered.isEmpty ? nil : .array(ordered.map(JSONValue.string)))
41
+ }
42
+
43
+ package mutating func setRating(_ question: Question, _ stars: Int) {
44
+ set(question, .int(Int64(min(max(stars, 1), 5))))
45
+ }
46
+
47
+ package mutating func setBoolean(_ question: Question, _ value: Bool) {
48
+ set(question, .bool(value))
49
+ }
50
+
51
+ /// A number typed in; anything that isn't one clears the answer.
52
+ package mutating func setNumber(_ question: Question, _ text: String) {
53
+ set(question, Self.parseNumber(text))
54
+ }
55
+
56
+ package mutating func setSlider(_ question: Question, _ value: Double) {
57
+ set(question, Self.normalise(min(max(value, Self.sliderMin), Self.sliderMax)))
58
+ }
59
+
60
+ /// Required questions still without an answer.
61
+ package var missing: [Question] { questions.filter { $0.required && answers[$0.id] == nil } }
62
+
63
+ package var isComplete: Bool { missing.isEmpty }
64
+
65
+ /// survey.submitted's data, in the contract's shape (events.json); nil until complete. (Never a
66
+ /// trap: an SDK must not bring down the app it's in.)
67
+ package func eventData(_ campaignNotificationId: String) -> [String: JSONValue]? {
68
+ guard isComplete else { return nil }
69
+ let list = questions.compactMap { question in
70
+ answers[question.id].map { JSONValue.object(["question_id": .string(question.id), "value": $0]) }
71
+ }
72
+ return ["campaign_notification_id": .string(campaignNotificationId), "answers": .array(list)]
73
+ }
74
+
75
+ /// Answers `question` (or clears it, for nil). A question of another survey is ignored.
76
+ private mutating func set(_ question: Question, _ value: JSONValue?) {
77
+ guard questions.contains(question) else {
78
+ BubblLog.warning("An answer to \"\(question.id)\", which isn't a question of this survey, was ignored")
79
+ return
80
+ }
81
+ answers[question.id] = value
82
+ }
83
+
84
+ /// A form filled from an app's own survey UI: `answers` by question id, as a choice id (single
85
+ /// choice), choice ids (multiple), a whole number 1–5 (rating), true/false, a number (number,
86
+ /// slider within its range) or text. Returns the form, or why it can't be sent.
87
+ package static func fromAnswers(_ questions: [Question], _ answers: [String: JSONValue]) -> Result<SurveyForm, SurveyProblem> {
88
+ var form = SurveyForm(questions)
89
+ let byId = Dictionary(questions.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
90
+
91
+ for (id, value) in answers.sorted(by: { $0.key < $1.key }) {
92
+ guard let question = byId[id] else { return .failure(SurveyProblem("\"\(id)\" isn't a question of this survey")) }
93
+ if value == .null { continue }
94
+ let choiceIds = Set(question.choices.map(\.id))
95
+ let problem: String?
96
+
97
+ switch question.kind {
98
+ case .singleChoice:
99
+ if let choice = value.stringValue, choiceIds.contains(choice) {
100
+ form.setChoice(question, choice)
101
+ problem = nil
102
+ } else {
103
+ problem = "one of its choice ids"
104
+ }
105
+ case .multipleChoice:
106
+ if case .array(let items) = value, items.allSatisfy({ $0.stringValue.map(choiceIds.contains) == true }) {
107
+ items.compactMap(\.stringValue).forEach { form.toggleChoice(question, $0, chosen: true) }
108
+ problem = nil
109
+ } else {
110
+ problem = "a list of its choice ids"
111
+ }
112
+ case .rating:
113
+ if let stars = value.number, stars == stars.rounded(), (1...5).contains(stars) {
114
+ form.setRating(question, Int(stars))
115
+ problem = nil
116
+ } else {
117
+ problem = "a whole number from 1 to 5"
118
+ }
119
+ case .boolean:
120
+ if case .bool(let answer) = value {
121
+ form.setBoolean(question, answer)
122
+ problem = nil
123
+ } else {
124
+ problem = "true or false"
125
+ }
126
+ case .number:
127
+ if let number = value.number, number.isFinite {
128
+ form.set(question, normalise(number))
129
+ problem = nil
130
+ } else {
131
+ problem = "a number"
132
+ }
133
+ case .slider:
134
+ if let number = value.number, (sliderMin...sliderMax).contains(number) {
135
+ form.setSlider(question, number)
136
+ problem = nil
137
+ } else {
138
+ problem = "a number from \(sliderMin) to \(sliderMax)"
139
+ }
140
+ case .openEnded:
141
+ if let text = value.stringValue, text.unicodeScalars.count <= maxText {
142
+ form.setText(question, text)
143
+ problem = nil
144
+ } else {
145
+ problem = "text of at most \(maxText) characters"
146
+ }
147
+ }
148
+
149
+ if let problem { return .failure(SurveyProblem("the answer to \"\(question.text)\" must be \(problem)")) }
150
+ }
151
+
152
+ if let missing = form.missing.first { return .failure(SurveyProblem("\"\(missing.text)\" needs an answer")) }
153
+ return .success(form)
154
+ }
155
+
156
+ /// A number as a person types it ("3", " 2,5 "); nil for anything else.
157
+ package static func parseNumber(_ text: String) -> JSONValue? {
158
+ let trimmed = text.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
159
+ guard !trimmed.isEmpty, let value = Double(trimmed), value.isFinite else { return nil }
160
+ return normalise(value)
161
+ }
162
+
163
+ /// Whole numbers are sent as integers (4, not 4.0), as a person would expect to see them.
164
+ private static func normalise(_ value: Double) -> JSONValue {
165
+ if value == value.rounded(), abs(value) < 9.0e15 { return .int(Int64(value)) }
166
+ return .double(value)
167
+ }
168
+ }
169
+
170
+ /// Why a survey's answers can't be sent, in words fit for a developer's log.
171
+ package struct SurveyProblem: Error, Equatable, CustomStringConvertible {
172
+ package let description: String
173
+ package init(_ description: String) { self.description = description }
174
+ }
175
+
176
+ extension JSONValue {
177
+ /// A number, whole or not; nil for anything else (true/false included).
178
+ package var number: Double? {
179
+ switch self {
180
+ case .int(let value): Double(value)
181
+ case .double(let value): value
182
+ default: nil
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,56 @@
1
+ #if canImport(CryptoKit)
2
+ import CryptoKit
3
+ #else
4
+ import Crypto
5
+ #endif
6
+ import Foundation
7
+
8
+ /// Signs device API v1 requests the way the backend verifies them (bubbl's
9
+ /// `App\Services\Api\ApiRequestSigner`): the hex HMAC-SHA256, keyed with the install's secret,
10
+ /// of `METHOD \n path \n canonical query \n timestamp \n body`. The contract's signing vectors
11
+ /// (contracts/v1/fixtures/v1/signing.json) pin every step, and RequestSignerContractTests
12
+ /// checks this file against them. CryptoKit on Apple platforms; swift-crypto (the same API)
13
+ /// where the core is built elsewhere.
14
+ package enum RequestSigner {
15
+ /// The X-Bubbl-Signature value. `path` has no leading slash ("api/v1/config"), `query` holds
16
+ /// the decoded values as they appear in the URL, and `body` is the exact bytes sent (empty
17
+ /// for a GET), since the server signs the raw body it receives.
18
+ package static func sign(secret: String, method: String, path: String, timestamp: Int, body: String, query: [String: String] = [:]) -> String {
19
+ let key = SymmetricKey(data: Data(secret.utf8))
20
+ let message = Data(stringToSign(method: method, path: path, timestamp: timestamp, body: body, query: query).utf8)
21
+
22
+ return HMAC<SHA256>.authenticationCode(for: message, using: key)
23
+ .map { String(format: "%02x", $0) }
24
+ .joined()
25
+ }
26
+
27
+ /// What gets signed: the five parts joined by newlines, the method upper-cased.
28
+ package static func stringToSign(method: String, path: String, timestamp: Int, body: String, query: [String: String] = [:]) -> String {
29
+ [method.uppercased(), path, canonicalQuery(query), String(timestamp), body].joined(separator: "\n")
30
+ }
31
+
32
+ /// The query parameters sorted by name and RFC 3986-encoded (a space is %20, not +; only
33
+ /// A–Z a–z 0–9 - . _ ~ are left as they are), so the order they were sent in doesn't matter.
34
+ /// The SDK only sends flat, lower-case ASCII names, for which sorting by code point matches
35
+ /// the server's ksort().
36
+ package static func canonicalQuery(_ query: [String: String]) -> String {
37
+ query.keys.sorted()
38
+ .map { "\(encode($0))=\(encode(query[$0] ?? ""))" }
39
+ .joined(separator: "&")
40
+ }
41
+
42
+ /// RFC 3986 percent-encoding of a string's UTF-8 bytes, hex in upper case (PHP's rawurlencode).
43
+ private static func encode(_ value: String) -> String {
44
+ var encoded = ""
45
+ for byte in value.utf8 {
46
+ switch byte {
47
+ case UInt8(ascii: "A")...UInt8(ascii: "Z"), UInt8(ascii: "a")...UInt8(ascii: "z"), UInt8(ascii: "0")...UInt8(ascii: "9"),
48
+ UInt8(ascii: "-"), UInt8(ascii: "."), UInt8(ascii: "_"), UInt8(ascii: "~"):
49
+ encoded.append(Character(UnicodeScalar(byte)))
50
+ default:
51
+ encoded += String(format: "%%%02X", byte)
52
+ }
53
+ }
54
+ return encoded
55
+ }
56
+ }