@bubblsdk/react-native-sdk 4.1.7 → 5.0.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BubblReactNativeSdk.podspec +29 -9
- package/CHANGELOG.md +52 -1
- package/LICENSE +202 -1
- package/README.md +172 -83
- package/android/build.gradle +33 -30
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblJson.kt +135 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkModule.kt +277 -0
- package/android/src/main/java/tech/bubbl/reactnative/BubblSdkPackage.kt +26 -0
- package/app.plugin.js +2 -0
- package/ios/BubblPush.swift +44 -0
- package/ios/BubblSdk.h +7 -0
- package/ios/BubblSdk.mm +252 -0
- package/ios/BubblSdkImpl.swift +335 -0
- package/ios-extension/BubblReactNativeNotificationService.podspec +44 -0
- package/ios-extension/Sources/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-extension/Sources/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-extension/Sources/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-extension/Sources/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-extension/Sources/BubblCore/API/Http.swift +39 -0
- package/ios-extension/Sources/BubblCore/API/ServerClock.swift +69 -0
- package/ios-extension/Sources/BubblCore/BubblVersion.swift +5 -0
- package/ios-extension/Sources/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-extension/Sources/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-extension/Sources/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-extension/Sources/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-extension/Sources/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-extension/Sources/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-extension/Sources/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-extension/Sources/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-extension/Sources/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-extension/Sources/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-extension/Sources/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-extension/Sources/BubblCore/Events/EventStore.swift +159 -0
- package/ios-extension/Sources/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-extension/Sources/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-extension/Sources/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-extension/Sources/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-extension/Sources/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-extension/Sources/BubblCore/Notification/Push.swift +134 -0
- package/ios-extension/Sources/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-extension/Sources/BubblCore/RequestSigner.swift +56 -0
- package/ios-extension/Sources/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-extension/Sources/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-extension/Sources/BubblCore/Support/JSON.swift +31 -0
- package/ios-extension/Sources/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-extension/Sources/BubblCore/Support/Locking.swift +11 -0
- package/ios-extension/Sources/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-extension/Sources/BubblNotificationService/BubblNotificationService.swift +124 -0
- package/ios-sdk/BubblCore/API/ApiResponse.swift +90 -0
- package/ios-sdk/BubblCore/API/CredentialStore.swift +116 -0
- package/ios-sdk/BubblCore/API/DeviceApiClient.swift +239 -0
- package/ios-sdk/BubblCore/API/ErrorActions.swift +68 -0
- package/ios-sdk/BubblCore/API/Http.swift +39 -0
- package/ios-sdk/BubblCore/API/ServerClock.swift +69 -0
- package/ios-sdk/BubblCore/BubblVersion.swift +5 -0
- package/ios-sdk/BubblCore/Device/ConfigSync.swift +216 -0
- package/ios-sdk/BubblCore/Device/DeviceSync.swift +160 -0
- package/ios-sdk/BubblCore/Device/ProvisioningProfile.swift +24 -0
- package/ios-sdk/BubblCore/Engine/EngineCore.swift +523 -0
- package/ios-sdk/BubblCore/Engine/Geofencing.swift +170 -0
- package/ios-sdk/BubblCore/Engine/Permissions.swift +90 -0
- package/ios-sdk/BubblCore/Engine/Privacy.swift +64 -0
- package/ios-sdk/BubblCore/Engine/PrivacyControls.swift +184 -0
- package/ios-sdk/BubblCore/Engine/Pushes.swift +61 -0
- package/ios-sdk/BubblCore/Engine/Sandbox.swift +149 -0
- package/ios-sdk/BubblCore/Events/EventQueue.swift +207 -0
- package/ios-sdk/BubblCore/Events/EventStore.swift +159 -0
- package/ios-sdk/BubblCore/Geofence/Geofence.swift +160 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceEngine.swift +488 -0
- package/ios-sdk/BubblCore/Geofence/GeofenceState.swift +103 -0
- package/ios-sdk/BubblCore/Geofence/RegionBudget.swift +62 -0
- package/ios-sdk/BubblCore/Notification/BubblNotification.swift +137 -0
- package/ios-sdk/BubblCore/Notification/NotificationEvents.swift +160 -0
- package/ios-sdk/BubblCore/Notification/Push.swift +134 -0
- package/ios-sdk/BubblCore/Notification/SurveyForm.swift +185 -0
- package/ios-sdk/BubblCore/RequestSigner.swift +56 -0
- package/ios-sdk/BubblCore/Support/AsyncMutex.swift +39 -0
- package/ios-sdk/BubblCore/Support/BubblLog.swift +52 -0
- package/ios-sdk/BubblCore/Support/JSON.swift +31 -0
- package/ios-sdk/BubblCore/Support/JSONValue.swift +94 -0
- package/ios-sdk/BubblCore/Support/Locking.swift +11 -0
- package/ios-sdk/BubblCore/Support/ValueStore.swift +109 -0
- package/ios-sdk/BubblLaunch/BubblLaunch.m +45 -0
- package/ios-sdk/BubblLaunch/include/BubblLaunch.h +5 -0
- package/ios-sdk/BubblSDK/Bubbl.swift +308 -0
- package/ios-sdk/BubblSDK/BubblBackend.swift +118 -0
- package/ios-sdk/BubblSDK/BubblCredential.swift +30 -0
- package/ios-sdk/BubblSDK/BubblMessage.swift +151 -0
- package/ios-sdk/BubblSDK/BubblOptions.swift +39 -0
- package/ios-sdk/BubblSDK/BubblPermissions.swift +79 -0
- package/ios-sdk/BubblSDK/BubblTestDeviceResult.swift +10 -0
- package/ios-sdk/BubblSDK/Engine/BackgroundRefresh.swift +92 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Geofences.swift +177 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Privacy.swift +65 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost+Push.swift +154 -0
- package/ios-sdk/BubblSDK/Engine/EngineHost.swift +356 -0
- package/ios-sdk/BubblSDK/Engine/SupportedBubbl.swift +320 -0
- package/ios-sdk/BubblSDK/Engine/SystemLog.swift +27 -0
- package/ios-sdk/BubblSDK/Engine/WorkRunner.swift +107 -0
- package/ios-sdk/BubblSDK/Notification/Look.swift +78 -0
- package/ios-sdk/BubblSDK/Notification/NotificationMedia.swift +329 -0
- package/ios-sdk/BubblSDK/Notification/NotificationModel.swift +172 -0
- package/ios-sdk/BubblSDK/Notification/NotificationScreen.swift +428 -0
- package/ios-sdk/BubblSDK/Notification/ScreenPresenter.swift +148 -0
- package/ios-sdk/BubblSDK/Permissions/PermissionFlow.swift +169 -0
- package/ios-sdk/BubblSDK/Permissions/PrivacyView.swift +110 -0
- package/ios-sdk/BubblSDK/Platform/AppleDevicePlatform.swift +95 -0
- package/ios-sdk/BubblSDK/Platform/KeychainCredentialStore.swift +114 -0
- package/ios-sdk/BubblSDK/Platform/LocationService.swift +418 -0
- package/ios-sdk/BubblSDK/Platform/PushIntegration.swift +288 -0
- package/ios-sdk/BubblSDK/Platform/URLSessionHttpClient.swift +69 -0
- package/ios-sdk/BubblSDK/PrivacyInfo.xcprivacy +76 -0
- package/jest/index.js +75 -0
- package/lib/commonjs/BubblModule.js +14 -0
- package/lib/commonjs/BubblModule.js.map +1 -0
- package/lib/commonjs/BubblModule.web.js +13 -0
- package/lib/commonjs/BubblModule.web.js.map +1 -0
- package/lib/commonjs/NativeBubblSdk.js +20 -0
- package/lib/commonjs/NativeBubblSdk.js.map +1 -0
- package/lib/commonjs/index.js +336 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/BubblModule.js +5 -0
- package/lib/module/BubblModule.js.map +1 -0
- package/lib/module/BubblModule.web.js +9 -0
- package/lib/module/BubblModule.web.js.map +1 -0
- package/lib/module/NativeBubblSdk.js +18 -0
- package/lib/module/NativeBubblSdk.js.map +1 -0
- package/lib/module/index.js +331 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/commonjs/package.json +1 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts +3 -0
- package/lib/typescript/commonjs/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/commonjs/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/commonjs/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/index.d.ts +111 -0
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/types.d.ts +192 -0
- package/lib/typescript/commonjs/src/types.d.ts.map +1 -0
- package/lib/typescript/module/package.json +1 -0
- package/lib/typescript/module/src/BubblModule.d.ts +3 -0
- package/lib/typescript/module/src/BubblModule.d.ts.map +1 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts +8 -0
- package/lib/typescript/module/src/BubblModule.web.d.ts.map +1 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts +59 -0
- package/lib/typescript/module/src/NativeBubblSdk.d.ts.map +1 -0
- package/lib/typescript/module/src/index.d.ts +111 -0
- package/lib/typescript/module/src/index.d.ts.map +1 -0
- package/lib/typescript/module/src/types.d.ts +192 -0
- package/lib/typescript/module/src/types.d.ts.map +1 -0
- package/package.json +105 -24
- package/plugin/build/imageNotifications.d.ts +35 -0
- package/plugin/build/imageNotifications.js +198 -0
- package/plugin/build/index.d.ts +84 -0
- package/plugin/build/index.js +176 -0
- package/src/BubblModule.ts +2 -0
- package/src/BubblModule.web.ts +9 -0
- package/src/NativeBubblSdk.ts +67 -0
- package/src/index.ts +338 -282
- package/src/types.ts +182 -0
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkModule.kt +0 -680
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkNotificationIntents.kt +0 -32
- package/android/src/main/kotlin/tech/bubbl/reactnative/BubblSdkPackage.kt +0 -14
- package/ios/BubblSdk.swift +0 -802
- package/ios/BubblSdkBridge.m +0 -86
- package/react-native.config.js +0 -11
- package/src/specs/NativeBubblSdk.ts +0 -54
|
@@ -0,0 +1,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
|