B Bubbl Docs

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.