@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,27 @@
1
+ import Foundation
2
+ import os
3
+ #if !COCOAPODS
4
+ import BubblCore
5
+ #endif
6
+
7
+ /// Points the core's log at the system log (Console, `log stream --subsystem tech.bubbl.sdk`).
8
+ /// Messages are public: the core's rule is that they carry ids, types and codes only. Errors also
9
+ /// reach the app's event listeners (BubblEvent.error), whatever the log level.
10
+ @available(iOS 17, *)
11
+ enum SystemLog {
12
+ private static let logger = Logger(subsystem: "tech.bubbl.sdk", category: "Bubbl")
13
+
14
+ static func use(_ level: BubblOptions.LogLevel) {
15
+ let writes = level.core != nil
16
+ BubblLog.setSink(level: level.core ?? .error) { level, message in
17
+ if level == .error { BubblEvents.shared.emit(.error(message: message)) }
18
+ guard writes else { return }
19
+ switch level {
20
+ case .error: logger.error("\(message, privacy: .public)")
21
+ case .warning: logger.warning("\(message, privacy: .public)")
22
+ case .info: logger.info("\(message, privacy: .public)")
23
+ case .debug: logger.debug("\(message, privacy: .public)")
24
+ }
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,107 @@
1
+ import Foundation
2
+ #if !COCOAPODS
3
+ import BubblCore
4
+ #endif
5
+
6
+ /// Runs the engine's work while the app is alive, as WorkManager does on Android: each job by name,
7
+ /// one at a time (asked for again while running, it runs once more after), retried with a growing
8
+ /// wait when it says `.retry`, and held back until the engine's data can be read: before the first
9
+ /// unlock after a reboot the Keychain and the engine's files are closed. From then on they stay
10
+ /// readable while the phone is locked, which is when most region events arrive, so nothing waits
11
+ /// for an unlock.
12
+ @available(iOS 17, *)
13
+ actor WorkRunner {
14
+ typealias Work = @Sendable () async -> WorkOutcome
15
+
16
+ private static let maxDelaySeconds = 15 * 60
17
+
18
+ /// Whether the engine's data can be read now. Once it can, it stays so until the next reboot
19
+ /// (which ends the process), so it's asked only until it says yes.
20
+ private let dataReadable: @Sendable () -> Bool
21
+ private var ready = false
22
+ private var running: Set<String> = []
23
+ private var again: [String: Work] = [:]
24
+ private var held: [String: Work] = [:]
25
+ private var attempts: [String: Int] = [:]
26
+ private var waits: [String: Task<Void, Never>] = [:]
27
+
28
+ init(dataReadable: @escaping @Sendable () -> Bool = { true }) {
29
+ self.dataReadable = dataReadable
30
+ }
31
+
32
+ /// Run `work` as `name` now (or once the engine's data can be read).
33
+ func submit(_ name: String, _ work: @escaping Work) {
34
+ guard isReady() else {
35
+ held[name] = work
36
+ return
37
+ }
38
+ start(name, work)
39
+ }
40
+
41
+ /// Ask again whether the engine's data can be read (the device was unlocked).
42
+ func recheck() {
43
+ _ = isReady()
44
+ }
45
+
46
+ /// Asks until the data can be read; the moment it can, held work starts.
47
+ private func isReady() -> Bool {
48
+ guard !ready else { return true }
49
+ ready = dataReadable()
50
+ if ready {
51
+ let waiting = held
52
+ held.removeAll()
53
+ waiting.forEach { start($0.key, $0.value) }
54
+ }
55
+ return ready
56
+ }
57
+
58
+ private func start(_ name: String, _ work: @escaping Work) {
59
+ guard !running.contains(name) else {
60
+ again[name] = work
61
+ return
62
+ }
63
+ // Asked for now: whatever retry was waiting is overtaken.
64
+ waits.removeValue(forKey: name)?.cancel()
65
+ running.insert(name)
66
+ Task {
67
+ let outcome = await work()
68
+ finished(name, outcome, work)
69
+ }
70
+ }
71
+
72
+ /// Forget every job not already running (Bubbl.stop, an opt-out).
73
+ func cancelAll() {
74
+ waits.values.forEach { $0.cancel() }
75
+ waits.removeAll()
76
+ again.removeAll()
77
+ held.removeAll()
78
+ attempts.removeAll()
79
+ }
80
+
81
+ private func finished(_ name: String, _ outcome: WorkOutcome, _ work: @escaping Work) {
82
+ running.remove(name)
83
+ switch outcome {
84
+ case .done:
85
+ attempts[name] = nil
86
+ if let next = again.removeValue(forKey: name) { submit(name, next) }
87
+ case .retry(let afterSeconds):
88
+ let attempt = (attempts[name] ?? 0) + 1
89
+ attempts[name] = attempt
90
+ let next = again.removeValue(forKey: name) ?? work
91
+ // 30 s, 1 min, 2 min… up to 15 min, never sooner than asked, with a little jitter.
92
+ let backoff = min(30 << min(attempt - 1, 10), Self.maxDelaySeconds)
93
+ let seconds = Double(max(afterSeconds, backoff)) * Double.random(in: 1.0...1.1)
94
+ waits[name]?.cancel()
95
+ waits[name] = Task { [weak self] in
96
+ try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
97
+ guard !Task.isCancelled else { return }
98
+ await self?.retry(name, next)
99
+ }
100
+ }
101
+ }
102
+
103
+ private func retry(_ name: String, _ work: @escaping Work) {
104
+ waits[name] = nil
105
+ submit(name, work)
106
+ }
107
+ }
@@ -0,0 +1,78 @@
1
+ #if os(iOS)
2
+ import SwiftUI
3
+ import UIKit
4
+
5
+ /// The notification screen's look. An app changes any colour with a colour set of the same name
6
+ /// in its asset catalogue (bubbl_accent, bubbl_surface…), light and dark, as Android's bubbl_
7
+ /// colour resources; the defaults follow the system's light and dark appearance.
8
+ @available(iOS 17, *)
9
+ enum Look {
10
+ static let scrim = color("bubbl_scrim", light: 0x000000, 0.60, dark: 0x000000, 0.70)
11
+ static let surface = color("bubbl_surface", light: 0xFFFFFF, dark: 0x1D2027)
12
+ static let onSurface = color("bubbl_on_surface", light: 0x16181D, dark: 0xF1F2F5)
13
+ static let onSurfaceMuted = color("bubbl_on_surface_muted", light: 0x5F6570, dark: 0xA9AEB8)
14
+ static let outline = color("bubbl_outline", light: 0xD9DCE1, dark: 0x3A3F4A)
15
+ static let accent = color("bubbl_accent", light: 0x3E4BDB, dark: 0x8C95FF)
16
+ static let onAccent = color("bubbl_on_accent", light: 0xFFFFFF, dark: 0x10123A)
17
+ static let error = color("bubbl_error", light: 0xC62828, dark: 0xFF8A80)
18
+ static let mediaSurface = color("bubbl_media_surface", light: 0xF1F2F5, dark: 0x2A2E37)
19
+
20
+ static let cardMaxWidth: CGFloat = 480
21
+ static let cardRadius: CGFloat = 20
22
+ static let cardPadding: CGFloat = 20
23
+ static let mediaMaxHeight: CGFloat = 240
24
+
25
+ private static func color(_ name: String, light: UInt32, _ lightAlpha: CGFloat = 1, dark: UInt32, _ darkAlpha: CGFloat = 1) -> Color {
26
+ if let custom = UIColor(named: name, in: .main, compatibleWith: nil) { return Color(uiColor: custom) }
27
+ return Color(uiColor: UIColor { traits in
28
+ traits.userInterfaceStyle == .dark ? rgb(dark, darkAlpha) : rgb(light, lightAlpha)
29
+ })
30
+ }
31
+
32
+ private static func rgb(_ hex: UInt32, _ alpha: CGFloat) -> UIColor {
33
+ UIColor(
34
+ red: CGFloat((hex >> 16) & 0xFF) / 255,
35
+ green: CGFloat((hex >> 8) & 0xFF) / 255,
36
+ blue: CGFloat(hex & 0xFF) / 255,
37
+ alpha: alpha
38
+ )
39
+ }
40
+ }
41
+
42
+ /// The notification screen's words, in English unless the app gives its own: a Bubbl.strings
43
+ /// table in the app (keys as Android's strings: bubbl_close, bubbl_next…), translated the usual way.
44
+ @available(iOS 17, *)
45
+ enum Words {
46
+ static func text(_ key: String, _ english: String) -> String {
47
+ Bundle.main.localizedString(forKey: key, value: english, table: "Bubbl")
48
+ }
49
+
50
+ static var close: String { text("bubbl_close", "Close") }
51
+ static var submit: String { text("bubbl_submit", "Send answers") }
52
+ static var thanks: String { text("bubbl_thanks", "Thanks for your answers") }
53
+ static var answerRequired: String { text("bubbl_answer_required", "Please answer this question to carry on") }
54
+ static var next: String { text("bubbl_next", "Next") }
55
+ static var back: String { text("bubbl_back", "Back") }
56
+ static var done: String { text("bubbl_done", "Done") }
57
+ static var yes: String { text("bubbl_yes", "Yes") }
58
+ static var no: String { text("bubbl_no", "No") }
59
+ static var answerHint: String { text("bubbl_answer_hint", "Your answer") }
60
+ static var numberHint: String { text("bubbl_number_hint", "A number") }
61
+ static var sliderUnanswered: String { text("bubbl_slider_unanswered", "Drag to answer") }
62
+ static var play: String { text("bubbl_play_media", "Play") }
63
+ static var pause: String { text("bubbl_pause_media", "Pause") }
64
+ static var open: String { text("bubbl_open_media", "Open") }
65
+
66
+ static func progress(_ step: Int, of total: Int) -> String {
67
+ String(format: text("bubbl_question_progress", "Question %1$d of %2$d"), step, total)
68
+ }
69
+
70
+ static func ratingStar(_ value: Int) -> String {
71
+ String(format: text("bubbl_rating_star", "%1$d of 5"), value)
72
+ }
73
+
74
+ static func mediaDescription(_ headline: String) -> String {
75
+ String(format: text("bubbl_media_description", "Image for %1$@"), headline)
76
+ }
77
+ }
78
+ #endif
@@ -0,0 +1,329 @@
1
+ #if os(iOS)
2
+ import AVFoundation
3
+ import AVKit
4
+ import SwiftUI
5
+ import WebKit
6
+ #if !COCOAPODS
7
+ import BubblCore
8
+ #endif
9
+
10
+ /// The media at the top of the card. Images show as they are; video and YouTube show their
11
+ /// picture with a Play button and then play right there in the card (nothing plays, and no sound
12
+ /// starts, until the person asks); audio's cover picture only (its player is a row in the card);
13
+ /// other kinds (a PDF, a file) open outside.
14
+ @available(iOS 17, *)
15
+ struct MediaHeader: View {
16
+ @ObservedObject var model: NotificationModel
17
+ let media: BubblNotification.Media
18
+ @State private var playing = false
19
+
20
+ var body: some View {
21
+ switch media.kind {
22
+ case .image:
23
+ picture(height: Look.mediaMaxHeight)
24
+ .onAppear { if media.url != nil { model.mediaViewed() } }
25
+ case .video:
26
+ if playing, let url = media.url.flatMap(URL.init(string:)) {
27
+ InlineVideo(model: model, url: url).aspectRatio(16 / 9, contentMode: .fit)
28
+ } else {
29
+ videoPicture(button: media.url == nil ? nil : Words.play)
30
+ }
31
+ case .youtube:
32
+ if let id = media.url.flatMap(YouTube.videoId) {
33
+ if playing {
34
+ YouTubePlayer(videoId: id)
35
+ .aspectRatio(16 / 9, contentMode: .fit)
36
+ .onAppear { model.mediaViewed() }
37
+ } else {
38
+ videoPicture(button: Words.play)
39
+ }
40
+ } else {
41
+ openable
42
+ }
43
+ case .audio:
44
+ picture(height: Look.mediaMaxHeight)
45
+ case .application, .text, .file:
46
+ openable
47
+ }
48
+ }
49
+
50
+ /// Media the card can't show: its picture, and a button that opens it outside.
51
+ private var openable: some View {
52
+ picture(height: Look.mediaMaxHeight).overlay {
53
+ if let url = media.url, LinkPolicy.canOpen(url) {
54
+ OverlayButton(title: Words.open) { model.openMedia(url) }
55
+ }
56
+ }
57
+ }
58
+
59
+ private func videoPicture(button: String?) -> some View {
60
+ Color.clear
61
+ .aspectRatio(16 / 9, contentMode: .fit)
62
+ .overlay { picture(height: nil) }
63
+ .overlay {
64
+ if let button { OverlayButton(title: button) { playing = true } }
65
+ }
66
+ .clipped()
67
+ }
68
+
69
+ private func picture(height: CGFloat?) -> some View {
70
+ Look.outline
71
+ .frame(height: height)
72
+ .overlay {
73
+ if let url = media.pictureUrl.flatMap(URL.init(string:)) {
74
+ AsyncImage(url: url) { phase in
75
+ if let image = phase.image { image.resizable().scaledToFill() }
76
+ }
77
+ }
78
+ }
79
+ .clipped()
80
+ .accessibilityElement()
81
+ .accessibilityLabel(Words.mediaDescription(model.notification.headline))
82
+ .accessibilityAddTraits(.isImage)
83
+ }
84
+ }
85
+
86
+ /// A video file, played in the card with the system's controls.
87
+ @available(iOS 17, *)
88
+ struct InlineVideo: View {
89
+ @ObservedObject var model: NotificationModel
90
+ let url: URL
91
+ @State private var player: AVPlayer?
92
+
93
+ var body: some View {
94
+ VideoPlayer(player: player)
95
+ .onAppear {
96
+ guard player == nil else { return }
97
+ Playback.prepareSession()
98
+ let created = AVPlayer(url: url)
99
+ player = created
100
+ created.play()
101
+ model.mediaViewed()
102
+ }
103
+ .onDisappear { player?.pause() }
104
+ .onReceive(NotificationCenter.default.publisher(for: AVPlayerItem.didPlayToEndTimeNotification)) { note in
105
+ guard let item = player?.currentItem, (note.object as? AVPlayerItem) === item else { return }
106
+ model.mediaCompleted(seconds: item.duration.seconds)
107
+ }
108
+ }
109
+ }
110
+
111
+ /// A YouTube video in YouTube's privacy-enhanced embedded player, in a web view kept to that one
112
+ /// page: no script bridge to the app, nothing kept between runs, and any link in it (Watch on
113
+ /// YouTube, the channel) opens outside rather than in the card. Its fullscreen button works. The
114
+ /// page is loaded with the app's own https origin as its address: YouTube refuses an embed that
115
+ /// doesn't say where it is (error 153).
116
+ @available(iOS 17, *)
117
+ struct YouTubePlayer: UIViewRepresentable {
118
+ let videoId: String
119
+
120
+ private static var origin: String {
121
+ "https://" + (Bundle.main.bundleIdentifier ?? "tech.bubbl.sdk").lowercased()
122
+ }
123
+
124
+ func makeCoordinator() -> Coordinator { Coordinator(origin: Self.origin) }
125
+
126
+ func makeUIView(context: Context) -> WKWebView {
127
+ let configuration = WKWebViewConfiguration()
128
+ configuration.allowsInlineMediaPlayback = true
129
+ // The person has just tapped Play: let the player start without a second tap.
130
+ configuration.mediaTypesRequiringUserActionForPlayback = []
131
+ configuration.websiteDataStore = .nonPersistent()
132
+ let web = WKWebView(frame: .zero, configuration: configuration)
133
+ web.navigationDelegate = context.coordinator
134
+ web.isOpaque = false
135
+ web.backgroundColor = .black
136
+ web.scrollView.isScrollEnabled = false
137
+ if let html = YouTube.embedHtml(videoId: videoId, origin: Self.origin) {
138
+ web.loadHTMLString(html, baseURL: URL(string: Self.origin))
139
+ }
140
+ return web
141
+ }
142
+
143
+ func updateUIView(_ web: WKWebView, context: Context) {}
144
+
145
+ static func dismantleUIView(_ web: WKWebView, coordinator: Coordinator) {
146
+ web.stopLoading()
147
+ web.loadHTMLString("", baseURL: nil)
148
+ }
149
+
150
+ @MainActor
151
+ final class Coordinator: NSObject, WKNavigationDelegate {
152
+ private let origin: String
153
+
154
+ init(origin: String) {
155
+ self.origin = origin
156
+ }
157
+
158
+ func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
159
+ // The embed's own frames (the player) load normally.
160
+ if let frame = navigationAction.targetFrame, !frame.isMainFrame { return .allow }
161
+ // The page itself, at the app's origin.
162
+ if let url = navigationAction.request.url, url.absoluteString.hasPrefix(origin) || url.scheme == "about" { return .allow }
163
+ // Anything else leaves the card: opened outside.
164
+ if let url = navigationAction.request.url, LinkPolicy.canOpen(url.absoluteString) {
165
+ _ = await UIApplication.shared.open(url)
166
+ }
167
+ return .cancel
168
+ }
169
+ }
170
+ }
171
+
172
+ /// Audio's player, a row in the card: play/pause, a position to drag, and elapsed / total time.
173
+ /// Nothing loads or plays until Play is tapped.
174
+ @available(iOS 17, *)
175
+ struct AudioRow: View {
176
+ @ObservedObject var model: NotificationModel
177
+ let url: URL
178
+ @StateObject private var playback = Playback()
179
+
180
+ var body: some View {
181
+ HStack(spacing: 12) {
182
+ Button {
183
+ playback.toggle(url) { model.mediaViewed() } completed: { model.mediaCompleted(seconds: $0) }
184
+ } label: {
185
+ Image(systemName: playback.playing ? "pause.fill" : "play.fill")
186
+ .font(.system(size: 18, weight: .semibold))
187
+ .foregroundStyle(Look.onAccent)
188
+ .frame(width: 44, height: 44)
189
+ .background(Look.accent, in: Circle())
190
+ .opacity(playback.loading ? 0.6 : 1)
191
+ }
192
+ .buttonStyle(.plain)
193
+ .disabled(playback.loading)
194
+ .accessibilityLabel(playback.playing ? Words.pause : Words.play)
195
+ .accessibilityIdentifier("bubbl.audio")
196
+
197
+ Slider(value: Binding(get: { playback.position }, set: { playback.seek($0) }), in: 0...max(playback.duration, 0.1))
198
+ .tint(Look.accent)
199
+ .disabled(!playback.started)
200
+ .accessibilityLabel(model.notification.headline)
201
+
202
+ Text("\(Self.clock(playback.position)) / \(Self.clock(playback.duration))")
203
+ .font(.footnote.monospacedDigit())
204
+ .foregroundStyle(Look.onSurfaceMuted)
205
+ }
206
+ .padding(EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 14))
207
+ .background(Look.mediaSurface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
208
+ .onDisappear { playback.stop() }
209
+ }
210
+
211
+ /// m:ss.
212
+ static func clock(_ seconds: Double) -> String {
213
+ let whole = seconds.isFinite ? max(Int(seconds), 0) : 0
214
+ return String(format: "%d:%02d", whole / 60, whole % 60)
215
+ }
216
+ }
217
+
218
+ /// One audio or video playback's state, for the audio row.
219
+ @available(iOS 17, *)
220
+ @MainActor
221
+ final class Playback: ObservableObject {
222
+ @Published private(set) var playing = false
223
+ @Published private(set) var loading = false
224
+ @Published private(set) var started = false
225
+ @Published private(set) var position: Double = 0
226
+ @Published private(set) var duration: Double = 0
227
+
228
+ private var player: AVPlayer?
229
+ private var timeObserver: Any?
230
+ private var statusObservation: NSKeyValueObservation?
231
+ private var endObserver: NSObjectProtocol?
232
+
233
+ /// Plays even with the ring/silent switch on, as a person who taps Play expects, unless the app
234
+ /// has set its own audio session up (then that's left as it is).
235
+ static func prepareSession() {
236
+ let session = AVAudioSession.sharedInstance()
237
+ if session.category == .soloAmbient { try? session.setCategory(.playback) }
238
+ }
239
+
240
+ func toggle(_ url: URL, viewed: @escaping @MainActor () -> Void, completed: @escaping @MainActor (Double) -> Void) {
241
+ if let player {
242
+ if playing { player.pause() } else { player.play() }
243
+ playing.toggle()
244
+ return
245
+ }
246
+ Self.prepareSession()
247
+ loading = true
248
+ let item = AVPlayerItem(url: url)
249
+ let created = AVPlayer(playerItem: item)
250
+ player = created
251
+ statusObservation = Self.observeStatus(item) { [weak self] status, seconds in
252
+ Task { @MainActor in
253
+ guard let self else { return }
254
+ switch status {
255
+ case .readyToPlay where !self.started:
256
+ self.loading = false
257
+ self.started = true
258
+ self.duration = seconds.isFinite ? seconds : 0
259
+ self.player?.play()
260
+ self.playing = true
261
+ viewed()
262
+ case .failed:
263
+ self.loading = false
264
+ self.stop()
265
+ default:
266
+ break
267
+ }
268
+ }
269
+ }
270
+ timeObserver = created.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), queue: .main) { [weak self] time in
271
+ let seconds = time.seconds
272
+ MainActor.assumeIsolated { self?.position = seconds.isFinite ? seconds : 0 }
273
+ }
274
+ endObserver = NotificationCenter.default.addObserver(forName: AVPlayerItem.didPlayToEndTimeNotification, object: item, queue: .main) { [weak self] _ in
275
+ MainActor.assumeIsolated {
276
+ guard let self else { return }
277
+ self.playing = false
278
+ completed(self.duration)
279
+ self.player?.seek(to: .zero)
280
+ self.position = 0
281
+ }
282
+ }
283
+ }
284
+
285
+ /// The item's status as it changes, on whatever thread AVFoundation reports it (so not main-actor
286
+ /// code, which would trap off the main thread).
287
+ nonisolated private static func observeStatus(_ item: AVPlayerItem, _ handler: @escaping @Sendable (AVPlayerItem.Status, Double) -> Void) -> NSKeyValueObservation {
288
+ item.observe(\.status) { item, _ in handler(item.status, item.duration.seconds) }
289
+ }
290
+
291
+ func seek(_ seconds: Double) {
292
+ position = seconds
293
+ player?.seek(to: CMTime(seconds: seconds, preferredTimescale: 600))
294
+ }
295
+
296
+ func stop() {
297
+ player?.pause()
298
+ if let timeObserver { player?.removeTimeObserver(timeObserver) }
299
+ if let endObserver { NotificationCenter.default.removeObserver(endObserver) }
300
+ statusObservation?.invalidate()
301
+ timeObserver = nil
302
+ endObserver = nil
303
+ statusObservation = nil
304
+ player = nil
305
+ playing = false
306
+ started = false
307
+ }
308
+ }
309
+
310
+ /// A button over media: Play, or Open.
311
+ @available(iOS 17, *)
312
+ struct OverlayButton: View {
313
+ let title: String
314
+ let action: () -> Void
315
+
316
+ var body: some View {
317
+ Button(action: action) {
318
+ Text(title)
319
+ .font(.body.weight(.semibold))
320
+ .foregroundStyle(Look.onAccent)
321
+ .padding(.horizontal, 24)
322
+ .frame(minHeight: 48)
323
+ .background(Look.accent, in: Capsule())
324
+ }
325
+ .buttonStyle(.plain)
326
+ .accessibilityIdentifier("bubbl.media")
327
+ }
328
+ }
329
+ #endif