BubblDocs

Usage – CocoaPods

📘 Objective Integrate the Bubbl iOS SDK into your Xcode project using CocoaPods. Note: This covers Bubbl SDK integration only—you can integrate Firebase using any method you prefer (CocoaPods, SPM, manual).
📘 Audience iOS Developers (Swift & Objective-C)

1. Prerequisites

  • Xcode 14 or later
  • Swift 5.7+ or Objective-C project
  • CocoaPods 1.11+ installed
    gem install cocoapods
  • Firebase project with Cloud Messaging enabled - Required for push notifications
  • Firebase Messaging SDK integrated in your app - You can use CocoaPods, SPM, or manual integration for Firebase

2. CocoaPods Setup

2.1 Create or Update Podfile

In your project root, create or update the

Podfile

install! 'cocoapods', :disable_input_output_paths => true

source 'https://bitbucket.org/bubbl-public/ios-public.git'
source 'https://cdn.cocoapods.org/'

platform :ios, '15.0'
use_frameworks! :linkage => :static   # ← static linkage

target 'YourApp' do
  # Firebase Messaging - REQUIRED for Bubbl SDK (if using CocoaPods for Firebase)
  pod 'Firebase/Messaging'  # Optional: only if you choose CocoaPods for Firebase
  
  # Bubbl SDK - THIS is what this guide covers
  pod 'Bubbl-Sdk', '2.0.7'  # Current stable version
end

post_install do |installer|
  # 1️⃣ Disable the macOS 14+ script sandbox
  require 'find'
  Find.find('Pods/Target Support Files') do |path|
    next unless path.end_with?('.sh')
    system('xattr', '-d', 'com.apple.quarantine', path) rescue nil
    system('xattr', '-d', 'com.apple.provenance', path) rescue nil
    system('chmod', '+x', path)
  end

  # 2️⃣ Delete unnecessary "[CP] ..." shell-script phases
  installer.pods_project.targets.each do |t|
    t.shell_script_build_phases
     .select { |p| p.name&.start_with?('[CP]') }
     .each { |p| t.build_phases.delete(p) }
  end
end
🔧 Minimum iOS version must be set to
'15.0'

Run

pod install or pod install --repo-update
and open the newly created
YourApp.xcworkspace
from now on.

2.2 Embed & Signing

1. Select your app’s target → “Frameworks, Libraries & Embedded Content.”
2. Click the “+” and choose BubblPlugin.xcframework.
3. Under the “Embed” column, select Embed & Sign.

3. Info.plist Permissions

Add the following keys to your

Info.plist
to explain why your app needs location and notification permissions:

<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to trigger timely, relevant notifications.</string>

<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Allow us to send notifications even when the app is in the background, so you never miss important alerts.</string>

4. Signing & Capabilities

In your app target → Signing & Capabilities, add:
  • Background Modes → Location updates
  • Push Notifications

5. Firebase Setup

Download

GoogleService-Info.plist
from the Firebase console and add it to your app target.

Then configure Firebase in your

AppDelegate.swift
(or
.m
for Objective-C):

import FirebaseCore

@main
struct YourApp: App {
  @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
  var body: some Scene { WindowGroup { ContentView() } }
}

class AppDelegate: NSObject, UIApplicationDelegate {
  func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    FirebaseApp.configure()
    return true
  }
}

6. Bubbl Initialization

🔥 Firebase Messaging Required: The Bubbl SDK depends on Firebase Cloud Messaging to obtain FCM tokens. You must have Firebase Messaging integrated in your app (via CocoaPods, SPM, or manual) before using Bubbl SDK.
⚠️ Critical: Initialize SDK Only After FCM Token is Available

The Bubbl SDK requires a valid FCM token to authenticate and register the device. Initializing before the token is ready can cause:
  • "device_token: pending" errors
  • Failed authentication on first launch
  • SDK not loading campaigns or geofences

Use the gated initialization pattern below to ensure the FCM token is available before starting the SDK. This pattern:
  • Forces token fetch on app launch using
    Messaging.messaging().token
  • Automatically retries if token not yet available
  • Prevents multiple initialization attempts
  • Ensures reliable authentication on fresh installs and TestFlight builds
⚠️ Multi-SDK Notification Delegate Limitation:

The Bubbl SDK sets itself as the UNUserNotificationCenter delegate and does not support delegate chaining.

If your app uses multiple SDKs that handle notifications:
  • Only ONE SDK can be the notification delegate at a time
  • The last SDK to set the delegate will override previous ones
  • This may cause notification handling conflicts with other SDKs

Note: Bubbl SDK must receive notification delegate callbacks to properly handle:
  • Notification presentation while app is in foreground
  • Notification tap events for analytics and deep linking
  • Campaign performance tracking

In your

AppDelegate
, configure Firebase, set up token handling, and initialize Bubbl:

import FirebaseCore
import FirebaseMessaging
import Bubbl  // Note: Import 'Bubbl', not 'BubblPlugin'
import UserNotifications

class AppDelegate: NSObject, UIApplicationDelegate, BubblPluginDelegate, MessagingDelegate {

  private var hasInitializedBubbl = false

  func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {

    // 1️⃣ Configure Firebase FIRST
    FirebaseApp.configure()

    // 2️⃣ Set up Firebase Messaging delegate
    Messaging.messaging().delegate = self

    // 3️⃣ Request push notification permission
    UNUserNotificationCenter.current()
      .requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
        if granted {
          DispatchQueue.main.async {
            application.registerForRemoteNotifications()
          }
        }
      }

    // 4️⃣ Fetch FCM token immediately (gated initialization)
    self.fetchFCMTokenAndInitialize()

    return true
  }

  // MARK: - APNs Token Handling
  func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
  ) {
    // Forward APNs token to Firebase
    Messaging.messaging().apnsToken = deviceToken

    // Try to initialize again (in case token is now available)
    self.fetchFCMTokenAndInitialize()
  }

  func application(
    _ application: UIApplication,
    didFailToRegisterForRemoteNotificationsWithError error: Error
  ) {
    print("❌ APNs registration failed: \(error.localizedDescription)")
    // Still try to initialize with whatever token we have
    self.fetchFCMTokenAndInitialize()
  }

  // MARK: - Firebase Messaging Delegate
  func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
    guard let fcmToken = fcmToken else { return }

    print("🔥 FCM token received: \(fcmToken)")

    // Forward token to Bubbl SDK
    BubblPlugin.updateFCMToken(fcmToken)

    // Try to initialize (gated by hasInitializedBubbl flag)
    self.fetchFCMTokenAndInitialize()
  }

  // MARK: - Gated Initialization
  private func fetchFCMTokenAndInitialize() {
    // Prevent multiple initializations
    guard !hasInitializedBubbl else { return }

    // Fetch FCM token (this works even on first launch)
    Messaging.messaging().token { [weak self] token, error in
      guard let self = self else { return }

      if let error = error {
        print("❌ Error fetching FCM token: \(error.localizedDescription)")
        // Retry after delay
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
          self.fetchFCMTokenAndInitialize()
        }
        return
      }

      guard let token = token else {
        print("⚠️ FCM token not yet available, will retry...")
        // Retry after delay
        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
          self.fetchFCMTokenAndInitialize()
        }
        return
      }

      // ✅ We have a valid FCM token - initialize Bubbl SDK
      print("🔥 FCM token available: \(token)")
      BubblPlugin.updateFCMToken(token)

      // Mark as initialized BEFORE calling start to prevent re-entry
      self.hasInitializedBubbl = true

      // Initialize Bubbl SDK
      self.initializeBubbl()
    }
  }

  private func initializeBubbl() {
    print("🔛 Initializing Bubbl SDK...")

    BubblPlugin.shared.start(
      apiKey:        "YOUR_API_KEY",
      env:           .staging,   // .production / .development
      segmentations: ["beta_users", "vip"],  // Optional: pass [] or omit for no segments
      delegate:      self
    )

    print("✅ Bubbl SDK initialization called")
  }

  // MARK: - BubblPluginDelegate callbacks
  func bubblPlugin(_ plugin: BubblPlugin, didAuthenticate deviceID: String, bubblID: String) {
    print("✅ Device authenticated: (deviceID)")
  }
  
  func bubblPlugin(_ plugin: BubblPlugin, didFailWith error: Error) {
    print("❌ Authentication failed: (error.localizedDescription)")
  }
}

6. API Reference

func start(
  apiKey:        String,
  env:           Config.Environment       = .staging,     // .development | .staging | .production
  segmentations: [String]                 = [],           // audience tags (empty by default)
  delegate:      BubblPluginDelegate?     = nil
)

What it does: Boots every SDK subsystem—device registration, geofence polling, live GPS, push-routing, analytics.
When to call: Once, as early as possible (e.g. in AppDelegate.application(_:didFinishLaunchingWithOptions:)).

// AppDelegate.swift
import FirebaseCore
import Bubbl

func application(_ app: UIApplication,
                 didFinishLaunchingWithOptions opts: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
  FirebaseApp.configure()  // Required for push notifications
  
  // Note: Call BubblPlugin.shared.start() after receiving FCM token
  // See complete example above in section 6
  
  return true
}
@objc public static func updateFCMToken(_ token: String)

What it does: Forwards FCM token to the SDK for Firebase Cloud Messaging.
When to call: In your FCM delegate when FCM token is received.

func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
    if let token = fcmToken {
        BubblPlugin.updateFCMToken(token)
    }
}
// Swift
public func updateSegments(
    segmentations: [String],
    completion: ((Result<Bool, Error>) -> Void)? = nil
)

// Objective-C
- (void)updateSegments:(NSArray *)segmentations
            completion:(void (^)(BOOL success, NSError *error))completion

What it does: Updates device segmentation tags after initialization. This allows you to dynamically change which campaigns/notifications the device receives based on user behavior or state changes.
When to call: After user login, subscription changes, preference updates, or any event that changes user segmentation.
Important: SDK must be initialized with start() before calling this method.

Swift Example:

// Update segments when user subscribes to premium
func userDidSubscribeToPremium() {
    BubblPlugin.shared.updateSegments(segmentations: ["premium", "subscribed"]) { result in
        switch result {
        case .success:
            print("✅ Segments updated successfully")
        case .failure(let error):
            print("❌ Failed to update segments: (error.localizedDescription)")
        }
    }
}

// Update segments based on user location region
func userDidChangeRegion(to region: String) {
    let newSegments = ["region_(region)", "active_user"]
    BubblPlugin.shared.updateSegments(segmentations: newSegments) { result in
        if case .success = result {
            print("User is now targeting region: (region)")
        }
    }
}

Objective-C Example:

// Update segments when user subscribes to premium
- (void)userDidSubscribeToPremium {
    NSArray *newSegments = @[@"premium", @"subscribed"];
    [BubblPlugin.shared updateSegments:newSegments completion:^(BOOL success, NSError *error) {
        if (success) {
            NSLog(@"✅ Segments updated successfully");
        } else {
            NSLog(@"❌ Failed to update segments: %@", error.localizedDescription);
        }
    }];
}

// Update segments based on user preferences
- (void)updateUserSegments {
    NSArray *segments = @[@"newsletter_subscriber", @"loyalty_member"];
    [BubblPlugin.shared updateSegments:segments completion:nil];  // No completion handler needed
}

Common Use Cases:

  • User Authentication: Update segments when user logs in/out
  • Subscription Changes: Premium, free, trial user segments
  • Location/Region: Geographic targeting based on user location
  • Preferences: Interest-based segments (sports, news, etc.)
  • Behavior: Active users, power users, new users
  • Demographics: Age groups, language preferences
💡 Best Practice: The segments you set here determine which campaigns this device receives. Make sure your segment names match those configured in your Bubbl campaigns on the dashboard.
func refetchGeofence()

What it does: Manually triggers a geofence fetch from the backend. This is useful for refreshing geofences on-demand without waiting for the automatic polling interval (5 minutes).

When to call:

  • After location permission is granted
  • When user enters a new region or city
  • After segments are updated (to get relevant geofences)
  • On app launch if geofences haven't loaded yet

Swift Example:

// Manually trigger geofence refresh
BubblPlugin.shared.refetchGeofence()

// Common use case: After location permission granted
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
    if manager.authorizationStatus == .authorizedWhenInUse ||
       manager.authorizationStatus == .authorizedAlways {
        // Refresh geofences now that we have location permission
        BubblPlugin.shared.refetchGeofence()
    }
}

Objective-C Example:

// Manually trigger geofence refresh
[BubblPlugin.shared refetchGeofenceObjC];

// Common use case: After location permission granted
- (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager {
    CLAuthorizationStatus status = manager.authorizationStatus;
    if (status == kCLAuthorizationStatusAuthorizedWhenInUse ||
        status == kCLAuthorizationStatusAuthorizedAlways) {
        // Refresh geofences now that we have location permission
        [BubblPlugin.shared refetchGeofenceObjC];
    }
}
💡 Note: The SDK automatically fetches geofences on initialization and every 5 minutes. Use this method only when you need immediate updates.
func getPrivacyText() -> String
func refreshPrivacyText(completion: ((Result<String, Error>) -> Void)?)

What they do:

  • getPrivacyText() - Returns cached privacy text without network call
  • refreshPrivacyText() - Fetches latest configuration from backend

When to use: Display privacy policy text in settings, consent screens, or onboarding flows. The text is configured in your Bubbl dashboard.

Swift Example:

// Get cached privacy text
let privacyText = BubblPlugin.shared.getPrivacyText()
print("Privacy: \(privacyText)")

// Refresh from backend
BubblPlugin.shared.refreshPrivacyText { result in
    switch result {
    case .success(let text):
        print("Updated privacy text: \(text)")
        // Update UI with new text
        self.privacyLabel.text = text
    case .failure(let error):
        print("Failed to refresh: \(error)")
    }
}

// Access full configuration (advanced)
if let config = BubblPlugin.shared.getCurrentConfiguration() {
    print("Notifications count: \(config.notificationsCount)")
    print("Days count: \(config.daysCount)")
    print("Battery count: \(config.batteryCount)")
    print("Privacy text: \(config.privacyText)")
}

Objective-C Example:

// Get cached privacy text
NSString *privacyText = [BubblPlugin.shared getPrivacyTextObjC];
NSLog(@"Privacy: %@", privacyText);

// Refresh from backend
[BubblPlugin.shared refreshPrivacyText:^(NSString *text, NSError *error) {
    if (error) {
        NSLog(@"Failed to refresh: %@", error);
    } else {
        NSLog(@"Updated privacy text: %@", text);
        // Update UI with new text
        self.privacyLabel.text = text;
    }
}];
💡 Configuration Updates: The configuration is fetched automatically on SDK initialization. Use refreshPrivacyText() only when you need to show the latest version to users.
// BubblPluginDelegate
func bubblPlugin(_ plugin: BubblPlugin,
                 didAuthenticate deviceID: String,
                 bubblID: String)

func bubblPlugin(_ plugin: BubblPlugin,
                 didFailWith error: Error)

What they do: Authentication callbacks when the SDK successfully registers the device or encounters an error. Use these for initialization confirmation and error handling.

Note: For geofence events, use the GeofenceService or ForegroundGeofenceMonitor classes which provide real-time location-based callbacks.

func requestLocationWhenInUse()

What it does: Requests location permission for when the app is in use.
When to call: When you need location access during app usage.

func requestLocationAlways()

What it does: Requests location permission for background location access.
When to call: When you need location access in the background for geofencing.

func requestPushPermission()

What it does: Requests push notification permission.
When to call: When you want to send push notifications to the user.

// Location updates you can observe
func locationManager(_ mgr: CLLocationManager,
                     didUpdateLocations locs: [CLLocation])

func locationManagerDidChangeAuthorization(_ mgr: CLLocationManager)

Why expose them: If you want your ownCLLocationManager delegate tap-through without breaking Bubbl’s internal handling, forward these calls.

public static let shared: BubblPlugin

Global singleton – used only for lightweight helpers; the main surface of the SDK is static.

public var locationAuthorizationStatus: CLAuthorizationStatus
– current location permission status
public var pushAuthorizationStatus: UNAuthorizationStatus
– current push notification permission status
public var locationAuthorizationStatusRaw: Int
– location status as integer (for KVC)
public var pushAuthorizationStatusRaw: Int
– push status as integer (for KVC)

Usage Examples

Swift - Checking Permission Status:

// Check current location permission status
let locationStatus = BubblPlugin.shared.locationAuthorizationStatus
switch locationStatus {
case .notDetermined:
    print("Location permission not yet requested")
    BubblPlugin.shared.requestLocationWhenInUse()
case .denied, .restricted:
    print("Location permission denied - show settings alert")
case .authorizedWhenInUse:
    print("Location permission granted for when in use")
    // Request always permission for geofencing
    BubblPlugin.shared.requestLocationAlways()
case .authorizedAlways:
    print("Location permission granted for always - ready for geofencing")
@unknown default:
    print("Unknown location permission status")
}

// Check push notification permission status
let pushStatus = BubblPlugin.shared.pushAuthorizationStatus
switch pushStatus {
case .notDetermined:
    print("Push permission not yet requested")
    BubblPlugin.shared.requestPushPermission()
case .denied:
    print("Push permission denied - show settings alert")
case .authorized:
    print("Push notifications enabled")
case .provisional:
    print("Push notifications provisionally authorized")
@unknown default:
    print("Unknown push permission status")
}

Objective-C - Using Raw Integer Values:

// Check location permission using raw integer values (useful for KVC)
NSInteger locationStatusRaw = [BubblPlugin shared].locationAuthorizationStatusRaw;
if (locationStatusRaw == 0) {
    NSLog(@"Location permission not determined");
} else if (locationStatusRaw == 1 || locationStatusRaw == 2) {
    NSLog(@"Location permission denied or restricted");
} else if (locationStatusRaw == 3) {
    NSLog(@"Location authorized when in use");
} else if (locationStatusRaw == 4) {
    NSLog(@"Location authorized always - ready for geofencing");
}

// Check push permission using raw integer values
NSInteger pushStatusRaw = [BubblPlugin shared].pushAuthorizationStatusRaw;
if (pushStatusRaw == 0) {
    NSLog(@"Push permission not determined");
} else if (pushStatusRaw == 1) {
    NSLog(@"Push permission denied");
} else if (pushStatusRaw == 2) {
    NSLog(@"Push notifications authorized");
}

SwiftUI - Reactive Updates:

import SwiftUI
import Combine
import Bubbl

struct PermissionStatusView: View {
    @State private var locationStatus: CLAuthorizationStatus = .notDetermined
    @State private var pushStatus: UNAuthorizationStatus = .notDetermined
    private var cancellables = Set<AnyCancellable>()
    
    var body: some View {
        VStack(spacing: 16) {
            // Location Status
            HStack {
                Text("Location:")
                Spacer()
                Text(locationStatusText)
                    .foregroundColor(locationStatusColor)
            }
            
            // Push Status
            HStack {
                Text("Push Notifications:")
                Spacer()
                Text(pushStatusText)
                    .foregroundColor(pushStatusColor)
            }
            
            // Action buttons
            if locationStatus != .authorizedAlways {
                Button("Request Location Permission") {
                    BubblPlugin.shared.requestLocationAlways()
                }
            }
            
            if pushStatus != .authorized {
                Button("Request Push Permission") {
                    BubblPlugin.shared.requestPushPermission()
                }
            }
        }
        .onAppear {
            updateStatus()
            setupStatusObservers()
        }
    }
    
    private func updateStatus() {
        locationStatus = BubblPlugin.shared.locationAuthorizationStatus
        pushStatus = BubblPlugin.shared.pushAuthorizationStatus
    }
    
    private func setupStatusObservers() {
        // Subscribe to permission changes
        BubblPlugin.locationAuthorizationPublisher
            .sink { status in
                locationStatus = status
            }
            .store(in: &cancellables)
            
        BubblPlugin.pushAuthorizationPublisher
            .sink { status in
                pushStatus = status
            }
            .store(in: &cancellables)
    }
    
    private var locationStatusText: String {
        switch locationStatus {
        case .notDetermined: return "Not Asked"
        case .denied, .restricted: return "Denied"
        case .authorizedWhenInUse: return "When In Use"
        case .authorizedAlways: return "Always"
        @unknown default: return "Unknown"
        }
    }
    
    private var pushStatusText: String {
        switch pushStatus {
        case .notDetermined: return "Not Asked"
        case .denied: return "Denied"
        case .authorized: return "Authorized"
        case .provisional: return "Provisional"
        @unknown default: return "Unknown"
        }
    }
    
    private var locationStatusColor: Color {
        switch locationStatus {
        case .authorizedAlways: return .green
        case .authorizedWhenInUse: return .orange
        case .denied, .restricted: return .red
        default: return .gray
        }
    }
    
    private var pushStatusColor: Color {
        switch pushStatus {
        case .authorized: return .green
        case .denied: return .red
        default: return .gray
        }
    }
}

3.10 SDK Capabilities & Best Practices

Automatic Logging

What it does: The SDK automatically logs important events to the console.
Why use it: No manual logging needed - the SDK handles all debugging information.
Where to see: Check Xcode console for logs prefixed with [Bubbl] or similar.

// SDK automatically logs:
// - Device registration
// - Geofence events  
// - Notification delivery
// - API calls
// - Error conditions
Combine Publishers (Swift Only)

What: Reactive streams for location and notification authorization status.
Why: Modern Swift integration with reactive programming patterns.
When: For SwiftUI apps or reactive architectures.

// Subscribe to location authorization changes
BubblPlugin.locationAuthorizationPublisher
    .sink { status in
        print("Location status: (status)")
    }
    .store(in: &cancellables)

// Subscribe to push authorization changes  
BubblPlugin.pushAuthorizationPublisher
    .sink { status in
        print("Push status: (status)")
    }
    .store(in: &cancellables)
Geofence Monitoring

What: Automatic geofence detection and notification triggering.
Why: Location-based notifications work out of the box.
How: SDK automatically monitors geofences when location permissions are granted.

// SDK automatically:
// - Monitors geofences in background
// - Triggers notifications on entry/exit
// - Handles location permission changes
// - Manages battery optimization
Firebase Integration

What: Seamless Firebase Cloud Messaging integration.
Why: Reliable push notification delivery.
How: Forward FCM tokens to the SDK for optimal delivery.

// Forward FCM token to SDK
func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
    if let token = fcmToken {
        BubblPlugin.updateFCMToken(token)
    }
}

3.11 Delegate & System Callbacks

// MARK: – BubblPluginDelegate

Implement on any class (often your AppDelegate or a dedicatedGeoObserver) to receive foreground polygon events:

extension AppDelegate: BubblPluginDelegate {
  func bubblPlugin(
      _ plugin: BubblPlugin,
      didAuthenticate deviceID: String,
      bubblID: String
  ) {
      // SDK successfully registered device
      print("Device authenticated: (deviceID)")
  }

  func bubblPlugin(
      _ plugin: BubblPlugin,
      didFailWith error: Error
  ) {
      // SDK registration failed
      print("Authentication failed: (error.localizedDescription)")
  }
}
// MARK: – UNUserNotificationCenterDelegate

Already wired inside NotificationManager, but expose it here in case you subclass further:

func userNotificationCenter(
  _ center: UNUserNotificationCenter,
  willPresent notification: UNNotification,
  withCompletionHandler completion: @escaping (UNNotificationPresentationOptions) -> Void
) { … }

func userNotificationCenter(
  _ center: UNUserNotificationCenter,
  didReceive response: UNNotificationResponse,
  withCompletionHandler completion: @escaping () -> Void
) { … }
// MARK: – CLLocationManagerDelegate

Exposed via LocationManager.shared if you want raw GPS hooks:

func locationManager(
  _ manager: CLLocationManager,
  didUpdateLocations locations: [CLLocation]
)

func locationManager(
  _ manager: CLLocationManager,
  didFailWithError error: Error
)

func locationManagerDidChangeAuthorization(_ manager: CLLocationManager)
Where should I hook these?  • For simple apps, keep everything in AppDelegate.
• For SwiftUI, create small ObservableObjects (e.g. GeoObserver,PushObserver) and inject them via .environmentObject.

7. BubblNotificationDetails API Reference

When a user taps a Bubbl notification, you receive a BubblNotificationDetails object with the following properties:

headline: String

The main title/headline of the notification

body: String

The body text content of the notification

mediaType: String?

The type of media attached (e.g., "image", "video", or nil for text-only)

mediaURL: String?

URL to the media file (image, video, etc.) if present

ctaLabel: String?

Label text for the call-to-action button (if present)

ctaURL: String?

URL to open when the call-to-action button is tapped

notifID: Int

Unique identifier for this notification (for analytics tracking)

locationID: Int

ID of the location that triggered this notification

questions: [SurveyQuestion]?

Array of survey questions if this notification contains a survey. Will be nil for regular notifications. See Section 9 for survey implementation details.

completionMessage: String?

Custom thank you message to display after survey completion. Configure this in the Bubbl dashboard when creating survey notifications.

8. Handling Tapped Notifications

The SDK provides NotificationManager to handle notification presentation and user interactions. For SwiftUI apps, you can use the published latest property to show notification details:

import SwiftUI
import Bubbl

struct ContentView: View {
    @ObservedObject private var notif = NotificationManager.shared
    
    var body: some View {
        NavigationView {
            // Your main UI here
        }
        // Present modal when notification is tapped
        .sheet(item: $notif.latest) { details in
            NotificationModalView(details: details)
        }
    }
}

NotificationModalView Implementation

Here's a complete implementation of NotificationModalView for displaying notification content:

import SwiftUI
import Bubbl

struct NotificationModalView: View {
    let details: BubblNotificationDetails
    @Environment(.dismiss) private var dismiss
    
    var body: some View {
        NavigationView {
            ScrollView {
                VStack(alignment: .leading, spacing: 16) {
                    // Headline
                    Text(details.headline)
                        .font(.title2)
                        .fontWeight(.bold)
                    
                    // Body
                    Text(details.body)
                        .font(.body)
                        .foregroundColor(.secondary)
                    
                    // Media content (if any)
                    if let mediaURL = details.mediaURL {
                        AsyncImage(url: URL(string: mediaURL)) { image in
                            image
                                .resizable()
                                .aspectRatio(contentMode: .fit)
                                .cornerRadius(12)
                        } placeholder: {
                            RoundedRectangle(cornerRadius: 12)
                                .fill(Color.gray.opacity(0.2))
                                .frame(height: 200)
                                .overlay(
                                    ProgressView()
                                )
                        }
                    }
                    
                    // Additional notification info
                    VStack(alignment: .leading, spacing: 8) {
                        Text("Notification Details")
                            .font(.headline)
                        
                        HStack {
                            Text("Type:")
                                .fontWeight(.medium)
                            Spacer()
                            Text(details.mediaType ?? "text")
                                .foregroundColor(.secondary)
                        }
                        
                        HStack {
                            Text("Notification ID:")
                                .fontWeight(.medium)
                            Spacer()
                            Text("(details.notifID)")
                                .foregroundColor(.secondary)
                        }
                        
                        HStack {
                            Text("Location ID:")
                                .fontWeight(.medium)
                            Spacer()
                            Text("(details.locationID)")
                                .foregroundColor(.secondary)
                        }
                    }
                    .padding()
                    .background(Color.gray.opacity(0.1))
                    .cornerRadius(8)
                    
                    // Call-to-action button
                    if let ctaLabel = details.ctaLabel, let ctaURL = details.ctaURL {
                        Link(destination: URL(string: ctaURL)!) {
                            HStack {
                                Text(ctaLabel)
                                Image(systemName: "arrow.up.right.square")
                            }
                            .frame(maxWidth: .infinity)
                            .padding()
                            .background(Color.blue)
                            .foregroundColor(.white)
                            .cornerRadius(10)
                        }
                    }
                }
                .padding()
            }
            .navigationTitle("Notification")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button("Done") {
                        dismiss()
                    }
                }
            }
        }
    }
}

For UIKit apps, observe the notification manager's changes:

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // Observe notification taps
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(handleNotificationTap),
            name: NSNotification.Name("BubblNotificationTapped"),
            object: nil
        )
    }
    
    @objc func handleNotificationTap() {
        if let details = NotificationManager.shared.latest {
            // Present your notification UI
            presentNotificationDetails(details)
        }
    }
}

9. Survey Integration

📋 Overview: The Bubbl iOS SDK provides comprehensive survey functionality that allows you to collect user feedback through location-triggered surveys delivered via push notifications. Surveys support 7 question types with built-in validation and analytics tracking.

9.1 Survey System Overview

Surveys in Bubbl are delivered as part of push notification payloads and can include multiple questions of different types. The SDK handles survey parsing, validation, and submission to the backend, while your host app is responsible for rendering the UI and collecting user responses.

Supported Question Types

  • BOOLEAN: Yes/No questions
  • SINGLE_CHOICE: Radio button selection from options
  • MULTIPLE_CHOICE: Checkbox selection (multiple options)
  • RATING: 1-5 star rating
  • SLIDER: Range slider with labeled options
  • OPEN_ENDED: Free text input
  • NUMBER: Numeric input

9.2 Survey Data Models

SurveyQuestion

Represents a single survey question received in the notification payload:

public struct SurveyQuestion: Codable {
    public let id: Int
    public let question: String
    public let questionType: QuestionType?
    public let hasChoices: Bool
    public let position: Int
    public let choices: [SurveyChoice]?
}

public struct SurveyChoice: Codable {
    public let id: Int
    public let choice: String
    public let position: Int
}

SurveyAnswer

Represents a user's answer to a survey question:

public struct SurveyAnswer: Codable {
    let questionId: Int
    let type: String
    let value: String
    let choice: [ChoiceSelection]?
}

public struct ChoiceSelection: Codable {
    let choiceId: Int
}

QuestionType Enum

public enum QuestionType: String, Codable {
    case boolean = "BOOLEAN"              // Yes/No questions
    case number = "NUMBER"                // Numeric input
    case openEnded = "OPEN_ENDED"         // Free text
    case rating = "RATING"                // 1-5 star rating
    case slider = "SLIDER"                // Range slider with choices
    case singleChoice = "SINGLE_CHOICE"   // Radio buttons
    case multipleChoice = "MULTIPLE_CHOICE" // Checkboxes
}

9.3 Survey API Reference

Submit Survey Response

BubblPlugin.shared.submitSurveyResponse(
    notificationId: String,
    locationId: String?,
    answers: [SurveyAnswer]
) { result in
    switch result {
    case .success(let success):
        print("Survey submitted: \(success)")
    case .failure(let error):
        print("Survey submission failed: \(error)")
    }
}

Parameters:

  • notificationId: The curated notification ID (String)
  • locationId: Location ID where survey was triggered, or nil if outside location
  • answers: Array of SurveyAnswer objects
  • completion: Optional callback with Result type

Validation Rules:

  • MULTIPLE_CHOICE/SINGLE_CHOICE/SLIDER: Must have choice selections
  • NUMBER: Value must be a valid integer
  • RATING: Value must be integer between 1-5
  • BOOLEAN: Value must be "true", "false", "yes", or "no"
  • OPEN_ENDED: Value cannot be empty or whitespace-only

Track Survey Events

// Track when survey modal is shown
BubblPlugin.shared.trackSurveyEvent(
    notificationId: "123",
    locationId: "456",
    activity: "notification_delivered"
) { result in
    print("Delivery tracked: \(result)")
}

// Track when user starts answering
BubblPlugin.shared.trackSurveyEvent(
    notificationId: "123",
    locationId: "456",
    activity: "cta_engagement"
) { result in
    print("Engagement tracked: \(result)")
}

// Track if user dismisses without completing
BubblPlugin.shared.trackSurveyEvent(
    notificationId: "123",
    locationId: "456",
    activity: "dismissed"
) { result in
    print("Dismissal tracked: \(result)")
}

Objective-C Compatibility

The SDK provides Objective-C compatible methods for survey functionality:

// Submit Survey Response (Objective-C)
[[BubblPlugin shared] submitSurveyResponse:@"123"
                               locationId:@"456"
                                  answers:answersArray
                               completion:^(BOOL success, NSError * _Nullable error) {
    if (success) {
        NSLog(@"Survey submitted successfully");
    } else {
        NSLog(@"Survey submission failed: %@", error.localizedDescription);
    }
}];

// Track Survey Event (Objective-C)
[[BubblPlugin shared] trackSurveyEvent:@"123"
                           locationId:@"456"
                             activity:@"notification_delivered"
                           completion:^(BOOL success, NSError * _Nullable error) {
    NSLog(@"Event tracked: %d", success);
}];

9.4 Detecting Survey Notifications

Check if a notification contains survey questions:

struct NotificationModalView: View {
    let details: BubblNotificationDetails

    var body: some View {
        if let questions = details.questions, !questions.isEmpty {
            // Show survey UI
            SurveyView(
                questions: questions,
                notificationId: details.notifID,
                locationId: details.locationID,
                completionMessage: details.completionMessage
            )
        } else {
            // Show regular notification UI
            RegularNotificationView(details: details)
        }
    }
}

9.5 SwiftUI Survey Implementation

Here's a complete SwiftUI survey view implementation:

import SwiftUI
import Bubbl

struct SurveyView: View {
    let questions: [SurveyQuestion]
    let notificationId: Int
    let locationId: Int
    let completionMessage: String?

    @State private var answers: [Int: SurveyAnswerData] = [:]
    @State private var currentQuestionIndex = 0
    @State private var isSubmitting = false
    @State private var showThankYou = false
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        if showThankYou {
            // Thank you screen
            VStack(spacing: 20) {
                Image(systemName: "checkmark.circle.fill")
                    .font(.system(size: 60))
                    .foregroundColor(.green)

                Text(completionMessage ?? "Thank you for your feedback!")
                    .font(.title2)
                    .multilineTextAlignment(.center)

                Button("Done") {
                    dismiss()
                }
                .buttonStyle(.borderedProminent)
            }
            .padding()
        } else {
            // Survey form
            VStack(spacing: 0) {
                // Progress indicator
                ProgressView(value: progress)
                    .padding()

                Text("Question \(currentQuestionIndex + 1) of \(questions.count)")
                    .font(.caption)
                    .foregroundColor(.secondary)

                ScrollView {
                    VStack(alignment: .leading, spacing: 20) {
                        let question = questions[currentQuestionIndex]

                        // Question text
                        Text(question.question)
                            .font(.title3)
                            .fontWeight(.bold)

                        // Question input based on type
                        questionInputView(for: question)
                    }
                    .padding()
                }

                // Navigation buttons
                HStack {
                    if currentQuestionIndex > 0 {
                        Button("Previous") {
                            currentQuestionIndex -= 1
                        }
                        .buttonStyle(.bordered)
                    }

                    Spacer()

                    if currentQuestionIndex < questions.count - 1 {
                        Button("Next") {
                            currentQuestionIndex += 1
                        }
                        .buttonStyle(.borderedProminent)
                        .disabled(!isCurrentQuestionAnswered)
                    } else {
                        Button(isSubmitting ? "Submitting..." : "Submit") {
                            submitSurvey()
                        }
                        .buttonStyle(.borderedProminent)
                        .disabled(isSubmitting || !areAllQuestionsAnswered)
                    }
                }
                .padding()
            }
            .onAppear {
                trackDelivered()
                trackCTAEngagement()
            }
        }
    }

    @ViewBuilder
    private func questionInputView(for question: SurveyQuestion) -> some View {
        switch question.questionType {
        case .boolean:
            BooleanQuestionView(
                answer: binding(for: question.id)
            )
        case .rating:
            RatingQuestionView(
                answer: binding(for: question.id)
            )
        case .number:
            NumberQuestionView(
                answer: binding(for: question.id)
            )
        case .openEnded:
            OpenEndedQuestionView(
                answer: binding(for: question.id)
            )
        case .singleChoice:
            SingleChoiceQuestionView(
                question: question,
                answer: binding(for: question.id)
            )
        case .multipleChoice:
            MultipleChoiceQuestionView(
                question: question,
                answer: binding(for: question.id)
            )
        case .slider:
            SliderQuestionView(
                question: question,
                answer: binding(for: question.id)
            )
        case .none:
            EmptyView()
        }
    }

    private func binding(for questionId: Int) -> Binding<SurveyAnswerData> {
        Binding(
            get: { answers[questionId] ?? SurveyAnswerData() },
            set: { answers[questionId] = $0 }
        )
    }

    private var progress: Double {
        Double(currentQuestionIndex + 1) / Double(questions.count)
    }

    private var isCurrentQuestionAnswered: Bool {
        guard let answer = answers[questions[currentQuestionIndex].id] else {
            return false
        }
        return !answer.isEmpty
    }

    private var areAllQuestionsAnswered: Bool {
        questions.allSatisfy { question in
            guard let answer = answers[question.id] else { return false }
            return !answer.isEmpty
        }
    }

    private func submitSurvey() {
        isSubmitting = true

        let surveyAnswers: [SurveyAnswer] = questions.compactMap { question in
            guard let answerData = answers[question.id],
                  let questionType = question.questionType else {
                return nil
            }

            let value: String
            let choiceSelections: [ChoiceSelection]?

            switch questionType {
            case .boolean, .number, .openEnded, .rating:
                value = answerData.value
                choiceSelections = nil
            case .singleChoice:
                value = answerData.value
                choiceSelections = answerData.selectedChoices.map {
                    ChoiceSelection(choiceId: $0)
                }
            case .multipleChoice:
                value = "YES"
                choiceSelections = answerData.selectedChoices.map {
                    ChoiceSelection(choiceId: $0)
                }
            case .slider:
                if let sliderValue = answerData.sliderValue,
                   let choices = question.choices {
                    let index = Int(sliderValue)
                    let selectedChoice = choices[index]
                    value = "\(index)"
                    choiceSelections = [ChoiceSelection(choiceId: selectedChoice.id)]
                } else {
                    return nil
                }
            }

            return SurveyAnswer(
                questionId: question.id,
                type: questionType.rawValue,
                value: value,
                choice: choiceSelections
            )
        }

        BubblPlugin.shared.submitSurveyResponse(
            notificationId: "\(notificationId)",
            locationId: "\(locationId)",
            answers: surveyAnswers
        ) { result in
            isSubmitting = false

            switch result {
            case .success:
                showThankYou = true
            case .failure(let error):
                print("Survey submission failed: \(error)")
                // Show error alert
            }
        }
    }

    private func trackDelivered() {
        BubblPlugin.shared.trackSurveyEvent(
            notificationId: "\(notificationId)",
            locationId: "\(locationId)",
            activity: "notification_delivered"
        ) { _ in }
    }

    private func trackCTAEngagement() {
        BubblPlugin.shared.trackSurveyEvent(
            notificationId: "\(notificationId)",
            locationId: "\(locationId)",
            activity: "cta_engagement"
        ) { _ in }
    }
}

struct SurveyAnswerData {
    var value: String = ""
    var selectedChoices: [Int] = []
    var sliderValue: Double? = nil

    var isEmpty: Bool {
        value.isEmpty && selectedChoices.isEmpty && sliderValue == nil
    }
}

9.6 Question Type UI Components

Boolean Question

struct BooleanQuestionView: View {
    @Binding var answer: SurveyAnswerData

    var body: some View {
        HStack(spacing: 12) {
            Button {
                answer.value = "true"
            } label: {
                Text("Yes")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(answer.value == "true" ? Color.blue : Color.gray.opacity(0.2))
                    .foregroundColor(answer.value == "true" ? .white : .primary)
                    .cornerRadius(10)
            }

            Button {
                answer.value = "false"
            } label: {
                Text("No")
                    .frame(maxWidth: .infinity)
                    .padding()
                    .background(answer.value == "false" ? Color.blue : Color.gray.opacity(0.2))
                    .foregroundColor(answer.value == "false" ? .white : .primary)
                    .cornerRadius(10)
            }
        }
    }
}

Rating Question

struct RatingQuestionView: View {
    @Binding var answer: SurveyAnswerData

    var body: some View {
        HStack(spacing: 8) {
            ForEach(1...5, id: \.self) { rating in
                Button {
                    answer.value = "\(rating)"
                } label: {
                    Image(systemName: rating <= Int(answer.value) ?? 0 ? "star.fill" : "star")
                        .font(.system(size: 40))
                        .foregroundColor(rating <= Int(answer.value) ?? 0 ? .yellow : .gray)
                }
            }
        }
    }
}

Single Choice Question

struct SingleChoiceQuestionView: View {
    let question: SurveyQuestion
    @Binding var answer: SurveyAnswerData

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            ForEach(question.choices ?? [], id: \.id) { choice in
                Button {
                    answer.value = choice.choice
                    answer.selectedChoices = [choice.id]
                } label: {
                    HStack {
                        Image(systemName: answer.selectedChoices.contains(choice.id) ?
                              "circle.fill" : "circle")
                        Text(choice.choice)
                        Spacer()
                    }
                    .padding()
                    .background(Color.gray.opacity(0.1))
                    .cornerRadius(8)
                }
                .foregroundColor(.primary)
            }
        }
    }
}

Multiple Choice Question

struct MultipleChoiceQuestionView: View {
    let question: SurveyQuestion
    @Binding var answer: SurveyAnswerData

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            ForEach(question.choices ?? [], id: \.id) { choice in
                Button {
                    if answer.selectedChoices.contains(choice.id) {
                        answer.selectedChoices.removeAll { $0 == choice.id }
                    } else {
                        answer.selectedChoices.append(choice.id)
                    }
                } label: {
                    HStack {
                        Image(systemName: answer.selectedChoices.contains(choice.id) ?
                              "checkmark.square.fill" : "square")
                        Text(choice.choice)
                        Spacer()
                    }
                    .padding()
                    .background(Color.gray.opacity(0.1))
                    .cornerRadius(8)
                }
                .foregroundColor(.primary)
            }
        }
    }
}

9.7 Survey Troubleshooting

  • Survey Questions Not Appearing?
    1. Verify "questions" field is present in notification payload
    2. Check that questions JSON is properly formatted
    3. Ensure questionType is not nil (SDK filters null types)
    4. Check notification logs for parsing errors
  • Submission Failing with Validation Error?
    1. Verify answer types match question types
    2. Check RATING answers are integers 1-5
    3. Ensure BOOLEAN answers use valid values ("true"/"false"/"yes"/"no")
    4. Verify choice-based questions include choice selections
    5. Check NUMBER answers are valid integers
  • Post-Survey Message Not Showing?
    1. Check notification payload includes "completionMessage" field
    2. Note: The completionMessage field can sometimes be lost in transit - always provide a default fallback message
    3. Display after successful submission callback
    4. Use custom UI to show message in thank you screen
    Example: completionMessage ?? "Thank you for your feedback!"

10. Common Problems & Solutions

  • Framework not found at runtime: Ensure you embedded & signed and that the .xcframework appears under your app target’s “Embedded Content.”
  • Push notifications never arrive:
    • Confirm you called registerForRemoteNotifications().
    • Verify your Firebase Cloud Messaging setup.
  • Geofences not triggering:
    • Make sure “Location updates” is checked in Background Modes.
    • Check your campaigns and locations in platform and their status.
  • App crashes at startup: Look at the device logs for missing or mis-signed frameworks; check your “Embedded Content” settings again.
  • Pod install fails: Run
    pod repo update
    and try again.
  • Push notifications not arriving: Confirm that
    deviceToken
    is being forwarded correctly and check your FCM setup.