ChangelogEdit pageSTAFFOpen dashboard
Migration · iOS · Android · React Native · Flutter

Migrating from 4.x to 5.x

A clean break: one start call, automatic push, location and taps, and new names across the API. No 4.x data carries over, and each install registers as a new device.

0 of 19 done
Update the dependency, and move iOS to Swift Package ManagerBump Bubbl to 5.x on every platform. On iOS, Bubbl 5 comes through Swift Package Manager only, not CocoaPods: remove pod 'BubblSDK' (and the Bubbl-Sdk alias, if you used it) from your Podfile, then in Xcode choose File › Add Package Dependencies…, enter https://github.com/bubbl-public/bubbl-ios-sdk and add the BubblSDK product to your app target. Android builds with compileSdk 35. The Flutter plugin needs Flutter 3.32 or later and carries the native SDKs itself, so there's no pod, Swift package or Gradle dependency to add. The React Native package needs React Native 0.76 or later with the New Architecture on, and builds the iOS SDK into its own pod.
Breaking
BEFORE · 4.x
// app/build.gradle.kts
dependencies {
    implementation("tech.bubbl.sdk:bubbl-sdk:4.1.7")
}
# Podfile
target 'MyApp' do
  pod 'BubblSDK', '4.1.7'
end
# pubspec.yaml
dependencies:
  bubbl_flutter_sdk: ^4.1.7
npm install @bubblsdk/react-native-sdk@4.1.7
cd ios && pod install
AFTER · 5.x
// app/build.gradle.kts
android {
    compileSdk = 35
}

dependencies {
    implementation("tech.bubbl.sdk:bubbl-sdk:5.0.0")
}
// Xcode: File › Add Package Dependencies…, then add the BubblSDK product to your app target.
// Or, in a Package.swift:
.package(url: "https://github.com/bubbl-public/bubbl-ios-sdk", exact: "5.0.0")
# pubspec.yaml
dependencies:
  bubbl_flutter_sdk: ^5.0.0
npm install @bubblsdk/react-native-sdk@5.0.0
cd ios && pod install
BEFORE · 4.x
class App : Application() {
    private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    override fun onCreate() {
        super.onCreate()
        BubblSdk.install(this)
        appScope.launch {
            BubblSdk.boot(BubblConfig(apiKey = "…"))
        }
    }
}
func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    Task {
        _ = try await BubblClient.shared.boot(BubblConfig(apiKey: "…"))
    }
    return true
}
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await BubblSdk.instance.boot(const BubblConfig(apiKey: '…'));
  runApp(const MyApp());
}
import { Bubbl } from '@bubblsdk/react-native-sdk';

Bubbl.boot({ apiKey: '…' });
AFTER · 5.x
class App : Application() {
    override fun onCreate() {
        super.onCreate()
        Bubbl.start(this, "pk_test_…", BubblOptions(baseUrl = "https://api.sandbox.bubbl.tech"))
    }
}
func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    Bubbl.start(apiKey: "pk_test_…", options: BubblOptions(baseUrl: "https://api.sandbox.bubbl.tech"))
    return true
}
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Bubbl.start(
    apiKey: 'pk_test_…',
    options: const BubblOptions(baseUrl: 'https://api.sandbox.bubbl.tech'),
  );
  runApp(const MyApp());
}
// index.js
import { Bubbl } from '@bubblsdk/react-native-sdk';

Bubbl.start('pk_test_…', { baseUrl: 'https://api.sandbox.bubbl.tech' });
BEFORE · 4.x
BubblSdk.boot(
    BubblConfig(
        apiKey = "…",
        environment = BubblEnvironment.Production,
        // or runtimeBaseUrl / transmissionBaseUrl / ingestBaseUrl overrides
    )
)
_ = try await BubblClient.shared.boot(
    BubblConfig(apiKey: "…", environment: .production)
    // or runtimeBaseUrl / transmissionBaseUrl / ingestBaseUrl overrides
)
await BubblSdk.instance.boot(
  const BubblConfig(apiKey: '…', environment: BubblEnvironment.production),
  // or runtimeBaseUrl / transmissionBaseUrl / ingestBaseUrl overrides
);
Bubbl.boot({ apiKey: '…', environment: 'production' });
// or runtimeBaseUrl / transmissionBaseUrl / ingestBaseUrl overrides
AFTER · 5.x
Bubbl.start(this, "pk_live_…", BubblOptions(baseUrl = "https://api.bubbl.tech"))
Bubbl.start(apiKey: "pk_live_…", options: BubblOptions(baseUrl: "https://api.bubbl.tech"))
await Bubbl.start(
  apiKey: 'pk_live_…',
  options: const BubblOptions(baseUrl: 'https://api.bubbl.tech'),
);
Bubbl.start('pk_live_…', { baseUrl: 'https://api.bubbl.tech' });
BEFORE · 4.x
lifecycleScope.launch {
    BubblSdk.boot(BubblConfig(apiKey = "…", enableLocationTracking = true))
    BubblSdk.startLocationTracking()
}
// later
BubblSdk.stopLocationTracking()
let monitor = BubblLocationMonitor()
monitor.requestAlwaysAuthorization()
monitor.start()
// later
monitor.stop()
await BubblSdk.instance.boot(
  const BubblConfig(apiKey: '…', enableLocationTracking: true),
);
await BubblSdk.instance.startLocationTracking();
// later
await BubblSdk.instance.stopLocationTracking();
await Bubbl.boot({ apiKey: '…', enableLocationTracking: true });
await Bubbl.startLocationTracking();
// AppDelegate.swift: BubblSdkLocationLaunchHandler.handleLaunchOptions(launchOptions as NSDictionary?)
AFTER · 5.x
// Nothing to start. To turn geofences off, and back on:
Bubbl.setLocationEnabled(false)
Bubbl.setLocationEnabled(true)
// Nothing to start. To turn geofences off, and back on:
Bubbl.setLocationEnabled(false)
Bubbl.setLocationEnabled(true)
// Nothing to start. To turn geofences off, and back on:
await Bubbl.setLocationEnabled(false);
await Bubbl.setLocationEnabled(true);
// Nothing to start. To turn geofences off, and back on:
Bubbl.setLocationEnabled(false);
Bubbl.setLocationEnabled(true);
BEFORE · 4.x
class MyMessagingService : FirebaseMessagingService() {
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    override fun onMessageReceived(message: RemoteMessage) {
        scope.launch {
            BubblSdk.handleFirebasePayload(
                payload = message.data,
                messageId = message.messageId,
                notificationTitle = message.notification?.title,
                notificationBody = message.notification?.body
            )
        }
    }

    override fun onNewToken(token: String) {
        scope.launch { BubblSdk.syncFcmToken(token) }
    }
}
// At launch
BubblNotificationCenterDelegate.installDefault()
application.registerForRemoteNotifications()

func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    Task { try? await BubblClient.shared.updateAPNsToken(deviceToken) }
}

func application(_ application: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                 fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    Task {
        _ = try? await BubblClient.shared.handleRemoteNotification(userInfo)
        completionHandler(.newData)
    }
}
FirebaseMessaging.instance.onTokenRefresh.listen(BubblSdk.instance.registerPushToken);
FirebaseMessaging.onMessage.listen((message) {
  BubblSdk.instance.handleFirebasePayload(Map<String, String>.from(message.data));
});
import messaging from '@react-native-firebase/messaging';

messaging().onTokenRefresh((token) => Bubbl.registerPushToken(token));
messaging().onMessage(async (remoteMessage) => {
  await Bubbl.handleFirebasePayload(remoteMessage.data as Record<string, string>);
});
AFTER · 5.x
class MyMessagingService : FirebaseMessagingService() {
    override fun onMessageReceived(message: RemoteMessage) {
        if (Bubbl.isBubblMessage(message.data)) return
        // your own pushes
    }
    // No onNewToken forwarding: Bubbl reads the FCM token itself.
}
// Nothing to forward: Bubbl picks up the APNs token and its own pushes by itself.
// Where your own code needs to tell Bubbl's payloads apart:
if Bubbl.isBubblMessage(userInfo) { return }
FirebaseMessaging.onMessage.listen((message) {
  if (Bubbl.isBubblMessage(message.data)) return;
  // your own pushes
});
import messaging from '@react-native-firebase/messaging';

messaging().onMessage(async (remoteMessage) => {
  if (Bubbl.isBubblMessage(remoteMessage.data)) return;
  // your own pushes
});
BEFORE · 4.x
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        BubblSdk.openNotificationIntent(this, intent, BubblNotificationTapPresentation.DefaultModal)
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        setIntent(intent)
        BubblSdk.openNotificationIntent(this, intent, BubblNotificationTapPresentation.DefaultModal)
    }
}
Task {
    for await event in await BubblClient.shared.events {
        if case let .notificationTapped(payload, _) = event {
            // open your screen for payload
        }
    }
}
BubblSdk.instance.events.listen((event) {
  if (event is BubblNotificationTappedEvent) {
    // open your screen for event.payload
  }
});
// MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    BubblSdkNotificationIntents.openDefaultModal(this, intent)
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    setIntent(intent)
    BubblSdkNotificationIntents.openDefaultModal(this, intent)
}
AFTER · 5.x
// Nothing in MainActivity. To react to a tap:
Bubbl.addEventListener { event ->
    when (event) {
        is BubblEvent.NotificationOpened -> { /* event.message */ }
        else -> Unit
    }
}
let token = Bubbl.addEventListener { event in
    if case let .notificationOpened(message) = event {
        // message.id, message.headline…
    }
}
Bubbl.addEventListener((event) {
  if (event is BubblNotificationOpened) {
    // event.message
  }
});
// Nothing in MainActivity. To react to a tap:
Bubbl.addEventListener((event) => {
  if (event.type === 'notification.opened') {
    // event.message
  }
});
BEFORE · 4.x
lifecycleScope.launch {
    BubblSdk.updateSegments(listOf("vip", "london"))
}
try await BubblClient.shared.updateSegments(["vip", "london"])
await BubblSdk.instance.updateSegments(['vip', 'london']);
await Bubbl.updateSegments(['vip', 'london']);
AFTER · 5.x
Bubbl.setSegments(listOf("vip", "london"))
Bubbl.setSegments(["vip", "london"])
await Bubbl.setSegments(['vip', 'london']);
Bubbl.setSegments(['vip', 'london']);
BEFORE · 4.x
lifecycleScope.launch {
    BubblSdk.track(BubblTrackEvent(type = "activity", activity = "checkout_started"))
}
try await BubblClient.shared.track(BubblTrackEvent(type: "activity", activity: "checkout_started"))
await BubblSdk.instance.track(
  const BubblTrackEvent(type: 'activity', activity: 'checkout_started'),
);
await Bubbl.track({ type: 'activity', activity: 'checkout_started' });
AFTER · 5.x
Bubbl.track("checkout_started", mapOf("basket_value" to 42.5))
Bubbl.track("checkout_started", properties: ["basket_value": 42.5])
await Bubbl.track('checkout_started', {'basket_value': 42.5});
Bubbl.track('checkout_started', { basket_value: 42.5 });
BEFORE · 4.x
lifecycleScope.launch {
    BubblSdk.setDefaultNotificationModalEnabled(false)
    BubblSdk.events.collect { event ->
        if (event is BubblEvent.NotificationReceived) {
            showMyCard(event.payload)
            BubblSdk.handleNotificationOpen(event.payload)
        }
    }
}
// Bubbl's modal for a stored notification:
// BubblSdk.openNotificationModal(context, payload)
try await BubblClient.shared.setDefaultNotificationModalEnabled(false)
for await event in await BubblClient.shared.events {
    if case let .notificationReceived(payload) = event {
        showMyCard(payload)
        try await BubblClient.shared.handleNotificationOpen(payload)
    }
}
// Bubbl's modal for a stored notification:
// _ = try await BubblClient.shared.openNotificationModal(payload)
await BubblSdk.instance.disableDefaultNotificationModal();
BubblSdk.instance.events.listen((event) {
  if (event is BubblNotificationReceivedEvent) {
    showMyCard(event.payload);
    BubblSdk.instance.handleNotificationOpen(event.payload);
  }
});
// Bubbl's modal for a stored notification:
// await BubblSdk.instance.openNotificationModal(payload);
await Bubbl.boot({ apiKey: '…', enableDefaultNotificationModal: false });
Bubbl.events.addListener((event) => {
  if (event.type === 'notificationReceived') {
    showMyBanner(event.payload);
    Bubbl.handleNotificationOpen(event.payload);
  }
});
// Bubbl's modal for a stored notification:
// await Bubbl.openNotificationModal(payload);
AFTER · 5.x
Bubbl.setNotificationListener { message ->
    showMyCard(message)
    Bubbl.reportDisplayed(message)
    true // false: Bubbl shows it
}
// Bubbl's screen for a message you held back:
Bubbl.present(message)
Bubbl.setNotificationListener { message in
    showMyCard(message)
    Bubbl.reportDisplayed(message)
    return true // false: Bubbl shows it
}
// Bubbl's screen for a message you held back:
Bubbl.present(message)
await Bubbl.setNotificationListener((message) async {
  showMyCard(message);
  await Bubbl.reportDisplayed(message);
  return true; // false: Bubbl shows it
});
// Bubbl's screen for a message you held back:
await Bubbl.present(message);
Bubbl.setNotificationListener((message) => {
  showMyBanner(message);
  Bubbl.reportDisplayed(message);
  return true; // false: Bubbl shows it
});
// Bubbl's screen for a message you held back:
Bubbl.present(message);
BEFORE · 4.x
lifecycleScope.launch {
    BubblSdk.setDefaultNotificationModalStyle(
        BubblNotificationModalStyle(theme = BubblNotificationModalTheme.Dark, accentColor = "#0F766E")
    )
}
try await BubblClient.shared.setDefaultNotificationModalStyle(
    BubblNotificationModalStyle(theme: .dark, accentColor: "#0F766E")
)
await BubblSdk.instance.setDefaultNotificationModalStyle(
  const BubblNotificationModalStyle(
    theme: BubblNotificationModalTheme.dark,
    accentColor: '#0F766E',
  ),
);
await Bubbl.setDefaultNotificationModalStyle({ theme: 'dark', accentColor: '#0F766E' });
AFTER · 5.x
<!-- res/values/colors.xml, and res/values-night/colors.xml for dark mode -->
<resources>
    <color name="bubbl_accent">#0F766E</color>
</resources>
Assets.xcassets › + › Color Set, named bubbl_accent.
Set Appearances to "Any, Dark" and choose a colour for each.
<!-- android/app/src/main/res/values/colors.xml (values-night for dark mode).
     On iOS, add a bubbl_accent colour set to ios/Runner/Assets.xcassets. -->
<resources>
    <color name="bubbl_accent">#0F766E</color>
</resources>
<!-- android/app/src/main/res/values/colors.xml (values-night for dark mode).
     On iOS, add a bubbl_accent colour set to your app's asset catalogue. -->
<resources>
    <color name="bubbl_accent">#0F766E</color>
</resources>
BEFORE · 4.x
lifecycleScope.launch {
    BubblSdk.setCorrelationId("user-123")
}
try await BubblClient.shared.setCorrelationId("user-123")
await BubblSdk.instance.setCorrelationId('user-123');
await Bubbl.setCorrelationId('user-123');
AFTER · 5.x
// No equivalent. This install's ID, for support:
lifecycleScope.launch {
    val installId = Bubbl.diagnostics().installId
}
// No equivalent. This install's ID, for support:
let installId = await Bubbl.diagnostics().installId
// No equivalent. This install's ID, for support:
final installId = (await Bubbl.diagnostics()).installId;
// No equivalent. This install's ID, for support:
const { installId } = await Bubbl.diagnostics();
BEFORE · 4.x
lifecycleScope.launch {
    val question = payload.survey?.questions?.firstOrNull() ?: return@launch
    BubblSdk.submitSurveyResponse(
        BubblSurveyResponse(
            curatedNotificationId = payload.curatedNotificationId ?: return@launch,
            answers = listOf(
                BubblSurveyAnswer(questionId = question.id, type = question.type, choiceIds = listOf(choiceId))
            )
        )
    )
}
guard let question = payload.survey?.questions.first,
      let notificationId = payload.curatedNotificationId else { return }
try await BubblClient.shared.submitSurveyResponse(
    BubblSurveyResponse(
        curatedNotificationId: notificationId,
        answers: [BubblSurveyAnswer(questionId: question.id, type: question.type, choiceIds: [choiceId])]
    )
)
final question = payload.survey!.questions.first;
await BubblSdk.instance.submitSurveyResponse(
  BubblSurveyResponse(
    curatedNotificationId: payload.curatedNotificationId!,
    answers: [
      BubblSurveyAnswer(questionId: question.id, type: question.type, choiceIds: [choiceId]),
    ],
  ),
);
const question = payload.survey?.questions?.[0];
if (!question || !payload.curatedNotificationId) return;
await Bubbl.submitSurveyResponse({
  curatedNotificationId: payload.curatedNotificationId,
  answers: [{ questionId: question.id, type: question.type, choiceIds: [choiceId] }],
});
AFTER · 5.x
val question = message.questions.first()
val sent = Bubbl.submitSurvey(message, mapOf(question.id to choiceId))
guard let question = message.questions.first else { return }
let sent = Bubbl.submitSurvey(message, answers: [question.id: choiceId])
final question = message.questions.first;
final sent = await Bubbl.submitSurvey(message, {question.id: choiceId});
const question = message.questions[0];
const sent = await Bubbl.submitSurvey(message, { [question.id]: choiceId });
BEFORE · 4.x
lifecycleScope.launch {
    BubblSdk.events.collect { event ->
        when (event) {
            is BubblEvent.GeofenceEntered -> println(event.transition.locationId)
            is BubblEvent.NotificationTapped -> println(event.payload.id)
            else -> Unit
        }
    }
}
Task {
    for await event in await BubblClient.shared.events {
        switch event {
        case .geofenceEntered(let transition): print(transition.locationId ?? "")
        case .notificationTapped(let payload, _): print(payload.id)
        default: break
        }
    }
}
BubblSdk.instance.events.listen((event) {
  if (event is BubblGeofenceEnteredEvent) print(event.transition.locationId);
  if (event is BubblNotificationTappedEvent) print(event.payload.id);
});
const subscription = Bubbl.events.addListener((event) => {
  if (event.type === 'geofenceEntered') console.log(event.transition.locationId);
  if (event.type === 'notificationTapped') console.log(event.payload.id);
});
AFTER · 5.x
val listener = BubblEventListener { event ->
    when (event) {
        is BubblEvent.GeofenceEntered -> println(event.locationId)
        is BubblEvent.NotificationOpened -> println(event.message.id)
        else -> Unit // new event types arrive in minor releases
    }
}
Bubbl.addEventListener(listener)
// later: Bubbl.removeEventListener(listener)
let token = Bubbl.addEventListener { event in
    switch event {
    case .geofenceEntered(let locationId): print(locationId)
    case .notificationOpened(let message): print(message.id)
    default: break // new event types arrive in minor releases
    }
}
// later: Bubbl.removeEventListener(token)
final subscription = Bubbl.addEventListener((event) {
  switch (event) {
    case BubblGeofenceEntered(:final locationId):
      print(locationId);
    case BubblNotificationOpened(:final message):
      print(message.id);
    default:
      break; // new event types arrive in minor releases
  }
});
// later: await Bubbl.removeEventListener(subscription);
const subscription = Bubbl.addEventListener((event) => {
  switch (event.type) {
    case 'geofence.entered':
      console.log(event.locationId);
      break;
    case 'notification.opened':
      console.log(event.message.id);
      break;
    default:
      break; // new event types arrive in minor releases
  }
});
// later: subscription.remove();
BEFORE · 4.x
// 4.x has no consent, opt-out or deletion calls: an app held back boot until the user agreed.
if (userAgreed) {
    appScope.launch { BubblSdk.boot(BubblConfig(apiKey = "…")) }
}
// 4.x has no consent, opt-out or deletion calls: an app held back boot until the user agreed.
if userAgreed {
    Task { _ = try await BubblClient.shared.boot(BubblConfig(apiKey: "…")) }
}
// 4.x has no consent, opt-out or deletion calls: an app held back boot until the user agreed.
if (userAgreed) {
  await BubblSdk.instance.boot(const BubblConfig(apiKey: '…'));
}
// 4.x has no consent, opt-out or deletion calls: an app held back boot until the user agreed.
if (userAgreed) {
  Bubbl.boot({ apiKey: '…' });
}
AFTER · 5.x
Bubbl.start(this, "pk_test_…", BubblOptions(baseUrl = "https://api.sandbox.bubbl.tech", requireConsent = true))
// later, when the user agrees:
Bubbl.setConsent(true)
// when the user opts out, or asks you to erase their data:
Bubbl.optOut()
Bubbl.deleteMyData()
Bubbl.start(apiKey: "pk_test_…", options: BubblOptions(baseUrl: "https://api.sandbox.bubbl.tech", requireConsent: true))
// later, when the user agrees:
Bubbl.setConsent(true)
// when the user opts out, or asks you to erase their data:
Bubbl.optOut()
Bubbl.deleteMyData()
await Bubbl.start(
  apiKey: 'pk_test_…',
  options: const BubblOptions(baseUrl: 'https://api.sandbox.bubbl.tech', requireConsent: true),
);
// later, when the user agrees:
await Bubbl.setConsent(true);
// when the user opts out, or asks you to erase their data:
await Bubbl.optOut();
await Bubbl.deleteMyData();
Bubbl.start('pk_test_…', { baseUrl: 'https://api.sandbox.bubbl.tech', requireConsent: true });
// later, when the user agrees:
Bubbl.setConsent(true);
// when the user opts out, or asks you to erase their data:
Bubbl.optOut();
Bubbl.deleteMyData();
BEFORE · 4.x
// 4.x: the app asked Android itself.
ActivityCompat.requestPermissions(
    activity,
    arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.POST_NOTIFICATIONS),
    REQUEST_CODE
)
// 4.x: the app asked iOS itself, or through BubblLocationMonitor.
let monitor = BubblLocationMonitor()
monitor.requestAlwaysAuthorization()
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
// 4.x: the Bubbl plugin had no permission calls. Apps asked through their own code
// or another plugin.
// 4.x: the Bubbl package had no permission calls. Apps asked through their own code
// or another library.
AFTER · 5.x
lifecycleScope.launch {
    Bubbl.permissions.requestNotifications()
    val status = Bubbl.permissions.requestLocation(always = true)
}
let notifications = await Bubbl.permissions.requestNotifications()
let location = await Bubbl.permissions.requestLocation(always: true)
await Bubbl.permissions.requestNotifications();
await Bubbl.permissions.requestLocation(always: true);
await Bubbl.permissions.requestNotifications();
await Bubbl.permissions.requestLocation({ always: true });
BEFORE · 4.x
BubblConfig(apiKey = "…", environment = BubblEnvironment.Staging)    // testing
BubblConfig(apiKey = "…", environment = BubblEnvironment.Production) // live
BubblConfig(apiKey: "…", environment: .staging)    // testing
BubblConfig(apiKey: "…", environment: .production) // live
const BubblConfig(apiKey: '…', environment: BubblEnvironment.staging);    // testing
const BubblConfig(apiKey: '…', environment: BubblEnvironment.production); // live
Bubbl.boot({ apiKey: '…', environment: 'staging' });    // testing
Bubbl.boot({ apiKey: '…', environment: 'production' }); // live
AFTER · 5.x
// Sandbox
Bubbl.start(this, "pk_test_…", BubblOptions(baseUrl = "https://api.sandbox.bubbl.tech"))
// Production
Bubbl.start(this, "pk_live_…", BubblOptions(baseUrl = "https://api.bubbl.tech"))
// Sandbox
Bubbl.start(apiKey: "pk_test_…", options: BubblOptions(baseUrl: "https://api.sandbox.bubbl.tech"))
// Production
Bubbl.start(apiKey: "pk_live_…", options: BubblOptions(baseUrl: "https://api.bubbl.tech"))
// Sandbox
await Bubbl.start(apiKey: 'pk_test_…', options: const BubblOptions(baseUrl: 'https://api.sandbox.bubbl.tech'));
// Production
await Bubbl.start(apiKey: 'pk_live_…', options: const BubblOptions(baseUrl: 'https://api.bubbl.tech'));
// Sandbox
Bubbl.start('pk_test_…', { baseUrl: 'https://api.sandbox.bubbl.tech' });
// Production
Bubbl.start('pk_live_…', { baseUrl: 'https://api.bubbl.tech' });
BEFORE · 4.x
// app/build.gradle.kts
android {
    defaultConfig {
        minSdk = 27 // 4.x's minimum
    }
}
# Podfile
platform :ios, '15.0' # 4.x's minimum
// android/app/build.gradle.kts (and iOS 15.0 in ios/Podfile)
android {
    defaultConfig {
        minSdk = 27 // 4.x's minimum
    }
}
// android/build.gradle (and iOS 15.0 for the app target)
buildscript {
    ext {
        minSdkVersion = 27 // 4.x's minimum
    }
}
AFTER · 5.x
// minSdk 23 or higher installs; Bubbl runs on API 27 and later.
if (!Bubbl.isSupported) {
    hideBubblSettings()
}
// iOS 13.0 or later installs; Bubbl runs on iOS 17 and later.
if !Bubbl.isSupported {
    hideBubblSettings()
}
// minSdk 23 / iOS 13.0 or higher installs; Bubbl runs on Android 8.1+ and iOS 17+.
if (!await Bubbl.isSupported) {
  hideBubblSettings();
}
// minSdkVersion 23 / iOS 13.0 or higher installs; Bubbl runs on Android 8.1+ and iOS 17+.
if (!Bubbl.isSupported) {
  hideBubblSettings();
}
BEFORE · 4.x
<!-- Merged in from the 4.x SDK's manifest -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<service
    android:name="tech.bubbl.sdk.BubblLocationUpdatesService"
    android:foregroundServiceType="location" />
// 4.x: the app showed its own disclosure and asked for background location itself.
// 4.x: the app showed its own disclosure and asked for background location itself.
No code change on this platform.
AFTER · 5.x
// Fill in the Play Console's Location permissions form, then ask through Bubbl,
// which shows the prominent disclosure first when the dashboard's privacy notice is on:
lifecycleScope.launch {
    Bubbl.permissions.requestLocation(always = true)
}
// Fill in the Play Console's Location permissions form, then:
await Bubbl.permissions.requestLocation(always: true);
// Fill in the Play Console's Location permissions form, then:
await Bubbl.permissions.requestLocation({ always: true });
No code change on this platform.
BEFORE · 4.x
// app/build.gradle.kts, with app/google-services.json in the project
plugins {
    id("com.android.application")
    id("com.google.gms.google-services")
}
// android/app/build.gradle.kts, with android/app/google-services.json in the project
plugins {
    id("com.android.application")
    id("com.google.gms.google-services")
}
// android/app/build.gradle, with android/app/google-services.json in the project
apply plugin: "com.android.application"
apply plugin: "com.google.gms.google-services"
No code change on this platform.
AFTER · 5.x
// app/build.gradle.kts, with google-services.json uploaded to the dashboard instead
plugins {
    id("com.android.application")
}
// android/app/build.gradle.kts, with google-services.json uploaded to the dashboard instead
plugins {
    id("com.android.application")
}
// android/app/build.gradle, with google-services.json uploaded to the dashboard instead
apply plugin: "com.android.application"
No code change on this platform.

Bubbl 5.x is a clean break from 4.x: nothing from the 4.x API keeps its old name, and there's no compatibility layer. Work through the changes below for your platform, then check the result on a device with Bubbl.diagnostics().

No 4.x data carries over5.x works only with fresh installs. On first launch it deletes what a 4.x install of the same app left behind (its queued events, credentials and saved state) and registers as a brand-new device, so every install counts as new.

SDK 5 runs on Android 8.1 (API 27) or later and iOS 17 or later. It still installs on lower versions (Android minSdk 23, iOS 13.0): there, Bubbl.start logs one line and does nothing else, and every other call is a safe no-op.

Base URLs before the 5.0 releaseUntil 5.0 is released, use https://api.sandbox.staging.bubbl.tech in place of https://api.sandbox.bubbl.tech, and https://api.staging.bubbl.tech in place of https://api.bubbl.tech.