iOS Setup (React Native Module)
This page covers the full iOS native bridge and the critical lifecycle wiring needed for campaign delivery.
1) Podfile dependencies
Bubbl requires static linkage for its frameworks on iOS.
In ios/Podfile:
source 'https://cdn.cocoapods.org/'
platform :ios, '15.1'
use_frameworks! :linkage => :static
target 'YourApp' do
config = use_native_modules!
use_react_native!(
:path => config[:reactNativePath],
:app_path => "#{Pod::Config.instance.installation_root}/.."
)
pod 'BubblSDK', '2.3.7'
pod 'FirebaseCore', :modular_headers => true
pod 'Firebase/Messaging'
end
If CocoaPods CDN has not indexed 2.3.7 yet in your environment, use:
pod 'BubblSDK', :git => 'https://github.com/bubbl-repo/bubbl-ios-sdk.git', :tag => '2.3.7'
Then run:
cd ios
pod install
2) Info.plist keys
Add the following keys to your Info.plist. These descriptions are required for App Store approval and functional background geofencing:
FirebaseAppDelegateProxyEnabled = NO: Recommended when handling notification delegates manually.
NSLocationWhenInUseUsageDescription: Required for foreground tracking.
NSLocationAlwaysAndWhenInUseUsageDescription: Required for background geofencing.
NSLocationAlwaysUsageDescription: Required for older iOS version compatibility.
UIBackgroundModes: Must include location and remote-notification.
3) AppDelegate Wiring (Firebase + APNs + FCM + Bubbl)
Configure the application lifecycle to forward tokens and interaction events to the Bubbl SDK. In your iOS app delegate:
import FirebaseCore
import FirebaseMessaging
import UserNotifications
import Bubbl
final class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
) -> Bool {
if FirebaseApp.app() == nil {
FirebaseApp.configure()
}
Messaging.messaging().delegate = self
UNUserNotificationCenter.current().delegate = NotificationManager.shared
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
if granted {
DispatchQueue.main.async { application.registerForRemoteNotifications() }
}
}
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Messaging.messaging().apnsToken = deviceToken
BubblPlugin.updateAPNsToken(deviceToken)
}
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
guard let token = fcmToken, !token.isEmpty else { return }
BubblPlugin.updateFCMToken(token)
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
// Forward for RN bridge observers if needed
NotificationCenter.default.post(
name: NSNotification.Name("BubblNotificationOpened"),
object: nil,
userInfo: response.notification.request.content.userInfo
)
completionHandler()
}
}
4) Core Bridge Implementation
Create both files in your iOS target (for example ios/YourApp/BubblModule.m and ios/YourApp/BubblModule.swift). These files define the JavaScript interface and implement the core SDK logic.
BubblModule.m (The Interface)
This file uses the RCT_EXTERN_MODULE macro to expose the Bubbl module and its methods to React Native. It includes definitions for over 20 methods, including boot, updateSegments, trackSurveyEvent, and testNotification.
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
@interface RCT_EXTERN_MODULE(Bubbl, RCTEventEmitter)
RCT_EXTERN_METHOD(addListener:(NSString *)eventName)
RCT_EXTERN_METHOD(removeListeners:(double)count)
RCT_EXTERN_METHOD(init:(NSString *)apiKey
withOptions:(NSDictionary *)options
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(boot:(NSString *)apiKey
environment:(NSString *)environment
options:(NSDictionary *)options
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(requiredPermissions:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(locationGranted:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(notificationGranted:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(requestPushPermission:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(startLocationTracking:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(refreshGeofence:(nonnull NSNumber *)lat
lng:(nonnull NSNumber *)lng)
RCT_EXTERN_METHOD(updateSegments:(NSArray<NSString *> *)segmentations
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getPrivacyText:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(refreshPrivacyText:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getCurrentConfiguration:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(hasCampaigns:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getCampaignCount:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(forceRefreshCampaigns:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(clearCachedCampaigns)
RCT_EXTERN_METHOD(getDeviceLogStreamInfo:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(getDeviceLogTail:(nonnull NSNumber *)maxLines
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(startDeviceLogStream:(NSDictionary *)options
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(stopDeviceLogStream)
RCT_EXTERN_METHOD(getApiKey:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(sayHello:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(sendEvent:(NSString *)curatedNotificationID
locationId:(NSString *)locationId
type:(NSString *)type
activity:(NSString *)activity
latitude:(nonnull NSNumber *)latitude
longitude:(nonnull NSNumber *)longitude
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(cta:(nonnull NSNumber *)notificationId
locationId:(NSString *)locationId)
RCT_EXTERN_METHOD(trackSurveyEvent:(NSString *)notificationId
locationId:(NSString *)locationId
activity:(NSString *)activity
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(submitSurveyResponse:(NSString *)notificationId
locationId:(NSString *)locationId
answers:(NSArray *)answers
withResolver:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
RCT_EXTERN_METHOD(startGeofenceUpdates)
RCT_EXTERN_METHOD(stopGeofenceUpdates)
RCT_EXTERN_METHOD(testNotification:(RCTPromiseResolveBlock)resolve
withRejecter:(RCTPromiseRejectBlock)reject)
@end
BubblModule.swift (The Implementation)
The Swift implementation handles:
-
Event Emission: Emits bubbl_notification and bubbl_geofence events to JS.
-
Survey Mapping: Converts native SurveyQuestion objects into JSON-friendly dictionaries for the React Native layer.
-
Lifecycle Management: Tracks whether the SDK has been initialized using requireInitialized to prevent runtime errors.
import Foundation
import React
import Bubbl
import Combine
import MapKit
import UserNotifications
import CoreLocation
@objc(Bubbl)
final class BubblModule: RCTEventEmitter, BubblPluginDelegate {
private var hasListeners = false
private var hasInitialized = false
private var activeApiKey: String = ""
private var notificationSubscription: AnyCancellable?
private var geofenceSubscription: AnyCancellable?
override static func requiresMainQueueSetup() -> Bool { false }
override func supportedEvents() -> [String]! {
["bubbl_notification", "bubbl_geofence", "bubbl_device_log"]
}
override func startObserving() { hasListeners = true }
override func stopObserving() { hasListeners = false }
override init() {
super.init()
NotificationManager.shared.setAsNotificationDelegate()
notificationSubscription = NotificationManager.shared.publisher
.receive(on: DispatchQueue.main)
.sink { [weak self] details in
self?.emitNotification(details)
}
}
override func invalidate() {
notificationSubscription?.cancel()
geofenceSubscription?.cancel()
super.invalidate()
}
private func requireInitialized(_ reject: RCTPromiseRejectBlock?, method: String) -> Bool {
if hasInitialized { return true }
reject?("BUBBL_NOT_INITIALIZED", "Call Bubbl.boot(...) before calling \(method)().", nil)
return false
}
private func emitNotification(_ details: BubblNotificationDetails) {
guard hasListeners else { return }
let payload: [String: Any] = [
"id": details.notifID,
"headline": details.headline,
"body": details.body,
"mediaUrl": details.mediaURL ?? NSNull(),
"mediaType": details.mediaType ?? NSNull(),
"ctaLabel": details.ctaLabel ?? NSNull(),
"ctaUrl": details.ctaURL ?? NSNull(),
"locationId": String(details.locationID),
"postMessage": details.completionMessage ?? NSNull(),
"questions": mapQuestions(details.questions) ?? NSNull(),
]
sendEvent(withName: "bubbl_notification", body: payload)
}
private func mapQuestions(_ questions: [SurveyQuestion]?) -> [[String: Any]]? {
guard let questions else { return nil }
return questions.map { q in
[
"id": q.id,
"question": q.question,
"question_type": q.questionType?.rawValue ?? NSNull(),
"has_choices": q.hasChoices,
"position": q.position,
"choices": q.choices?.map {
["id": $0.id, "choice": $0.choice, "position": $0.position]
} ?? [],
]
}
}
private func emitGeofence(polygons: [MKPolygon]) {
guard hasListeners else { return }
let mappedPolygons: [[String: Any]] = polygons.enumerated().map { idx, polygon in
[
"campaignId": idx,
"campaignName": polygon.title ?? "campaign-\(idx)",
"vertices": polygon.coordinates.map { ["latitude": $0.latitude, "longitude": $0.longitude] },
]
}
sendEvent(withName: "bubbl_geofence", body: [
"stats": [
"campaignsTotal": mappedPolygons.count,
"polygonsTotal": mappedPolygons.count,
],
"polygons": mappedPolygons,
"circles": [],
])
}
@objc(init:withOptions:withResolver:withRejecter:)
func `init`(
_ apiKey: String,
withOptions options: NSDictionary,
withResolver resolve: @escaping RCTPromiseResolveBlock,
withRejecter reject: @escaping RCTPromiseRejectBlock
) {
let environment = options["environment"] as? String ?? "STAGING"
boot(apiKey, environment: environment, options: options, withResolver: resolve, withRejecter: reject)
}
@objc(boot:environment:options:withResolver:withRejecter:)
func boot(
_ apiKey: String,
environment: String,
options: NSDictionary,
withResolver resolve: @escaping RCTPromiseResolveBlock,
withRejecter reject: @escaping RCTPromiseRejectBlock
) {
let env: Config.Environment = {
switch environment.uppercased() {
case "PRODUCTION": return .production
case "DEVELOPMENT": return .development
default: return .staging
}
}()
let tags = (options["segmentationTags"] as? [String]) ?? []
activeApiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
if activeApiKey.isEmpty {
reject("BUBBL_BOOT_FAILED", "apiKey is required.", nil)
return
}
BubblPlugin.shared.start(apiKey: activeApiKey, env: env, segmentations: tags, delegate: self)
hasInitialized = true
resolve([
"initializedNow": true,
"alreadyInitialized": false,
])
}
@objc(requiredPermissions:withRejecter:)
func requiredPermissions(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
resolve(["locationWhenInUse", "locationAlways", "pushNotifications"])
}
@objc(locationGranted:withRejecter:)
func locationGranted(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
let status = CLLocationManager.authorizationStatus()
resolve(status == .authorizedWhenInUse || status == .authorizedAlways)
}
@objc(notificationGranted:withRejecter:)
func notificationGranted(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
UNUserNotificationCenter.current().getNotificationSettings { settings in
let granted = settings.authorizationStatus == .authorized ||
settings.authorizationStatus == .provisional ||
settings.authorizationStatus == .ephemeral
resolve(granted)
}
}
@objc(requestPushPermission:withRejecter:)
func requestPushPermission(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if let error {
reject("BUBBL_PUSH_PERMISSION_FAILED", error.localizedDescription, error)
return
}
if granted {
DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() }
}
resolve(granted)
}
}
@objc(startLocationTracking:withRejecter:)
func startLocationTracking(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
guard requireInitialized(reject, method: "startLocationTracking") else { return }
BubblPlugin.shared.requestLocationWhenInUse()
BubblPlugin.shared.requestLocationAlways()
BubblPlugin.shared.refetchGeofence()
resolve(true)
}
@objc(refreshGeofence:lng:)
func refreshGeofence(_ lat: NSNumber, lng: NSNumber) {
guard hasInitialized else { return }
let selector = NSSelectorFromString("refetchGeofenceWithLatitude:longitude:")
let target = BubblPlugin.shared as NSObject
if target.responds(to: selector) {
_ = target.perform(selector, with: lat, with: lng)
} else {
BubblPlugin.shared.refetchGeofence()
}
}
@objc(updateSegments:withResolver:withRejecter:)
func updateSegments(
_ segmentations: [String],
withResolver resolve: @escaping RCTPromiseResolveBlock,
withRejecter reject: @escaping RCTPromiseRejectBlock
) {
guard requireInitialized(reject, method: "updateSegments") else { return }
BubblPlugin.shared.updateSegments(segmentations: segmentations) { result in
switch result {
case .success: resolve(true)
case .failure(let error): reject("BUBBL_SEGMENTS_FAILED", error.localizedDescription, error)
}
}
}
@objc(getPrivacyText:withRejecter:)
func getPrivacyText(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
resolve(BubblPlugin.shared.getPrivacyText())
}
@objc(refreshPrivacyText:withRejecter:)
func refreshPrivacyText(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
BubblPlugin.shared.refreshPrivacyText { result in
switch result {
case .success(let text): resolve(text)
case .failure(let error): reject("BUBBL_PRIVACY_FAILED", error.localizedDescription, error)
}
}
}
@objc(getCurrentConfiguration:withRejecter:)
func getCurrentConfiguration(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
guard let cfg = BubblPlugin.shared.getCurrentConfiguration() else {
resolve(nil)
return
}
resolve([
"notificationsCount": cfg.notificationsCount,
"daysCount": cfg.daysCount,
"batteryCount": cfg.batteryCount,
"privacyText": cfg.privacyText,
])
}
@objc(hasCampaigns:withRejecter:)
func hasCampaigns(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
guard requireInitialized(reject, method: "hasCampaigns") else { return }
resolve(GeofenceService.shared.currentPolygons.count > 0)
}
@objc(getCampaignCount:withRejecter:)
func getCampaignCount(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
guard requireInitialized(reject, method: "getCampaignCount") else { return }
resolve(GeofenceService.shared.currentPolygons.count)
}
@objc(forceRefreshCampaigns:withRejecter:)
func forceRefreshCampaigns(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
guard requireInitialized(reject, method: "forceRefreshCampaigns") else { return }
BubblPlugin.shared.refetchGeofence()
resolve(true)
}
@objc(clearCachedCampaigns)
func clearCachedCampaigns() {
// iOS SDK does not expose public geofence cache clearing.
}
@objc(getDeviceLogStreamInfo:withRejecter:)
func getDeviceLogStreamInfo(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
let deviceId = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
resolve([
"deviceType": "ios",
"deviceId": deviceId,
"deviceIdSuffix": String(deviceId.replacingOccurrences(of: "-", with: "").suffix(5)),
])
}
@objc(getDeviceLogTail:withResolver:withRejecter:)
func getDeviceLogTail(_ maxLines: NSNumber, withResolver resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
resolve([])
}
@objc(startDeviceLogStream:withResolver:withRejecter:)
func startDeviceLogStream(_ options: NSDictionary, withResolver resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
resolve(["started": false, "reason": "not_implemented", "deviceIdSuffix": "-----"])
}
@objc(stopDeviceLogStream)
func stopDeviceLogStream() {}
@objc(getApiKey:withRejecter:)
func getApiKey(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
resolve(activeApiKey)
}
@objc(sayHello:withRejecter:)
func sayHello(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
resolve("Hello from Bubbl iOS bridge")
}
@objc(sendEvent:locationId:type:activity:latitude:longitude:withResolver:withRejecter:)
func sendEvent(
_ curatedNotificationID: String,
locationId: String,
type: String,
activity: String,
latitude: NSNumber,
longitude: NSNumber,
withResolver resolve: @escaping RCTPromiseResolveBlock,
withRejecter reject: @escaping RCTPromiseRejectBlock
) {
guard requireInitialized(reject, method: "sendEvent") else { return }
BubblPlugin.shared.trackSurveyEvent(notificationId: curatedNotificationID, locationId: locationId, activity: activity) { result in
switch result {
case .success(let ok): resolve(ok)
case .failure(let error): reject("BUBBL_SEND_EVENT_FAILED", error.localizedDescription, error)
}
}
}
@objc(cta:locationId:)
func cta(_ notificationId: NSNumber, locationId: String) {
if let loc = Int(locationId) {
NotificationManager.shared.trackCTAEngagement(notificationID: notificationId.intValue, locationID: loc)
} else {
BubblPlugin.shared.trackSurveyEvent(
notificationId: String(notificationId.intValue),
locationId: locationId,
activity: "cta_engagement"
) { _ in }
}
}
@objc(trackSurveyEvent:locationId:activity:withResolver:withRejecter:)
func trackSurveyEvent(
_ notificationId: String,
locationId: String,
activity: String,
withResolver resolve: @escaping RCTPromiseResolveBlock,
withRejecter reject: @escaping RCTPromiseRejectBlock
) {
guard requireInitialized(reject, method: "trackSurveyEvent") else { return }
BubblPlugin.shared.trackSurveyEvent(notificationId: notificationId, locationId: locationId, activity: activity) { result in
switch result {
case .success(let ok): resolve(ok)
case .failure(let error): reject("BUBBL_SURVEY_EVENT_FAILED", error.localizedDescription, error)
}
}
}
@objc(submitSurveyResponse:locationId:answers:withResolver:withRejecter:)
func submitSurveyResponse(
_ notificationId: String,
locationId: String,
answers: NSArray,
withResolver resolve: @escaping RCTPromiseResolveBlock,
withRejecter reject: @escaping RCTPromiseRejectBlock
) {
guard requireInitialized(reject, method: "submitSurveyResponse") else { return }
var parsed: [SurveyAnswer] = []
for case let item as NSDictionary in answers {
guard let questionId = (item["question_id"] as? NSNumber)?.intValue ?? item["question_id"] as? Int else { continue }
let type = (item["type"] as? String) ?? ""
let value = (item["value"] as? String) ?? ""
var selections: [ChoiceSelection]? = nil
if let choiceRows = item["choice"] as? [NSDictionary] {
let mapped = choiceRows.compactMap { row -> ChoiceSelection? in
let id = (row["choice_id"] as? NSNumber)?.intValue ?? row["choice_id"] as? Int
guard let id else { return nil }
return ChoiceSelection(choiceId: id)
}
if !mapped.isEmpty { selections = mapped }
}
parsed.append(SurveyAnswer(questionId: questionId, type: type, value: value, choice: selections))
}
BubblPlugin.shared.submitSurveyResponse(notificationId: notificationId, locationId: locationId, answers: parsed) { result in
switch result {
case .success(let ok): resolve(ok)
case .failure(let error): reject("BUBBL_SURVEY_SUBMIT_FAILED", error.localizedDescription, error)
}
}
}
@objc(startGeofenceUpdates)
func startGeofenceUpdates() {
guard hasInitialized else { return }
if geofenceSubscription != nil { return }
geofenceSubscription = BubblPlugin.polygonsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] polygons in
self?.emitGeofence(polygons: polygons)
}
emitGeofence(polygons: GeofenceService.shared.currentPolygons)
}
@objc(stopGeofenceUpdates)
func stopGeofenceUpdates() {
geofenceSubscription?.cancel()
geofenceSubscription = nil
}
@objc(testNotification:withRejecter:)
func testNotification(_ resolve: @escaping RCTPromiseResolveBlock, withRejecter reject: @escaping RCTPromiseRejectBlock) {
NotificationManager.shared.sendTestNotification()
resolve(true)
}
func bubblPlugin(_ plugin: BubblPlugin, didAuthenticate deviceID: String, bubblID: String) {}
func bubblPlugin(_ plugin: BubblPlugin, didFailWith error: Error) {}
}
5) Keep NotificationManager as Delegate
To ensure high reliability, especially when using multiple SDKs that might conflict with the UNUserNotificationCenter delegate, re-assert Bubbl's NotificationManager shortly after application launch.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if UNUserNotificationCenter.current().delegate !== NotificationManager.shared {
NotificationManager.shared.setAsNotificationDelegate()
}
}
Next: see Method Reference, Usage Examples, and Modal Styling.