B Bubbl Docs

Android Setup (React Native Native Module)

This guide covers the native wiring required to connect the tech.bubbl.sdk:bubbl-sdk to your React Native application.

1) Gradle repositories and dependencies

android/settings.gradle

dependencyResolutionManagement {
  repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
  repositories {
    mavenLocal()
    google()
    mavenCentral()
  }
}

android/app/build.gradle

Ensure the Firebase BoM and Play Services are correctly implemented alongside the Bubbl SDK.

apply plugin: "com.google.gms.google-services"

dependencies {
  implementation("tech.bubbl.sdk:bubbl-sdk:2.3.7")
  implementation(platform("com.google.firebase:firebase-bom:33.3.0"))
  implementation("com.google.firebase:firebase-messaging-ktx:24.0.0")
  implementation("com.google.android.gms:play-services-location:21.2.0")
  implementation("com.google.android.gms:play-services-maps:19.1.0")
  implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
  implementation("androidx.work:work-runtime-ktx:2.11.0")
}

Maven Central propagation

If 2.3.7 is not yet resolvable in your region, publish the SDK locally and build against mavenLocal():

cd sdk/bubbl-android-sdk-standalone
./gradlew :sdk:publishToMavenLocal

2) Manifest & Permissions

Add the following to AndroidManifest.xml. Bubbl requires high-accuracy location and background access to trigger geofences when the app is backgrounded.

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
  <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
  <uses-permission android:name="android.permission.VIBRATE" />

  <application android:name=".MainApplication">
    <service
      android:name="tech.bubbl.sdk.services.LocationUpdatesService"
      android:foregroundServiceType="location" />

    <service
      android:name="tech.bubbl.sdk.services.MyFirebaseMessagingService"
      android:exported="false">
      <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
      </intent-filter>
    </service>

    <meta-data
      android:name="com.google.firebase.messaging.default_notification_channel_id"
      android:value="bubbl_push" />

    <meta-data
      android:name="com.google.android.geo.API_KEY"
      android:value="YOUR_GOOGLE_MAPS_KEY" />
  </application>
</manifest>

3) Add native bridge files

Create the package android/app/src/main/java/<your/package>/bubbl/ nd add the following files to handle the JS-to-Native communication:

BubblPackage.kt

Registers the module with React Native.

package com.example.app.bubbl

import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class BubblPackage : ReactPackage {
    override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
        listOf(BubblModule(reactContext))

    override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
        emptyList()
}

BubblInitManager.kt

Manages the SDK singleton lifecycle.

package com.example.app.bubbl

import android.app.Application
import tech.bubbl.sdk.BubblSdk
import tech.bubbl.sdk.config.BubblConfig

object BubblInitManager {
    @Volatile private var initialized = false

    fun isInitialized(): Boolean = initialized

    fun ensureInit(app: Application, config: BubblConfig): Boolean {
        if (initialized) return false
        synchronized(this) {
            if (initialized) return false
            BubblSdk.init(app, config)
            initialized = true
            return true
        }
    }
}

TenantConfigStore.kt

Handles persistence of API keys and environment settings.

package com.example.app.bubbl

import android.content.Context
import tech.bubbl.sdk.config.BubblConfig
import tech.bubbl.sdk.config.Environment

object TenantConfigStore {
    private const val PREFS_NAME = "bubbl_tenant_config"
    private const val KEY_API = "bubbl_api_key"
    private const val KEY_ENV = "bubbl_environment"

    data class TenantConfig(
        val apiKey: String,
        val environment: Environment,
    )

    fun load(context: Context): TenantConfig? {
        val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
        val api = prefs.getString(KEY_API, null)?.trim().orEmpty()
        if (api.isEmpty()) return null

        val envRaw = prefs.getString(KEY_ENV, Environment.STAGING.name)
        val env = runCatching { Environment.valueOf(envRaw ?: Environment.STAGING.name) }
            .getOrDefault(Environment.STAGING)

        return TenantConfig(apiKey = api, environment = env)
    }

    fun save(context: Context, apiKey: String, environment: Environment) {
        context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
            .edit()
            .putString(KEY_API, apiKey.trim())
            .putString(KEY_ENV, environment.name)
            .commit()
    }

    fun clear(context: Context) {
        context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
            .edit()
            .clear()
            .apply()
    }

    fun toBubblConfig(config: TenantConfig): BubblConfig =
        BubblConfig(
            apiKey = config.apiKey,
            environment = config.environment,
            segmentationTags = emptyList(),
            geoPollInterval = 5 * 60_000L,
            defaultDistance = 10,
        )
}

BubblModule.kt

The core bridge that maps all @ReactMethod calls to SDK actions.

@file:Suppress("DEPRECATION")

package com.example.app.bubbl

import android.Manifest
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
import kotlinx.coroutines.*
import org.json.JSONObject
import tech.bubbl.sdk.BubblSdk
import tech.bubbl.sdk.config.BubblConfig
import tech.bubbl.sdk.config.Environment
import tech.bubbl.sdk.models.ChoiceSelection
import tech.bubbl.sdk.models.SurveyAnswer
import tech.bubbl.sdk.notifications.NotificationRouter

class BubblModule(private val rc: ReactApplicationContext) : ReactContextBaseJavaModule(rc) {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    private var geofenceJob: Job? = null
    private var notificationBridgeRegistered = false

    override fun getName(): String = "Bubbl"

    override fun initialize() {
        super.initialize()
        ensureNotificationBridge()
    }

    private val activityListener = object : BaseActivityEventListener() {
        override fun onNewIntent(intent: Intent) {
            handleNotificationIntent(intent)
        }
    }

    private val notificationReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            val payload = intent?.getStringExtra("payload") ?: return
            emitNotification(payload)
        }
    }

    private fun emitEvent(event: String, map: WritableMap) {
        rc.runOnJSQueueThread {
            rc.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
                .emit(event, map)
        }
    }

    private fun requireInitialized(promise: Promise?, method: String): Boolean {
        if (BubblInitManager.isInitialized()) return true
        promise?.reject("BUBBL_NOT_INITIALIZED", "Call Bubbl.boot(...) before $method().")
        return false
    }

    private fun extractPayload(intent: Intent?): String? =
        intent?.getStringExtra("payload")
            ?: intent?.getStringExtra("notification_payload")
            ?: intent?.getStringExtra("data")

    private fun emitNotification(rawJson: String) {
        val map = Arguments.createMap().apply {
            putString("raw", rawJson)
            runCatching {
                val obj = JSONObject(rawJson)
                putInt("id", obj.optInt("id"))
                putString("headline", obj.optString("headline", null))
                putString("body", obj.optString("body", null))
                putString("mediaUrl", obj.optString("mediaUrl", null))
                putString("mediaType", obj.optString("mediaType", null))
                putString("locationId", obj.optString("locationId", null))
                putString("ctaLabel", obj.optString("ctaLabel", null))
                putString("ctaUrl", obj.optString("ctaUrl", null))
                putString("postMessage", obj.optString("postMessage", null))
            }
        }
        emitEvent("bubbl_notification", map)
    }

    private fun handleNotificationIntent(intent: Intent?) {
        val payload = extractPayload(intent) ?: return
        intent?.removeExtra("payload")
        emitNotification(payload)
    }

    private fun ensureNotificationBridge() {
        if (notificationBridgeRegistered) return
        notificationBridgeRegistered = true

        LocalBroadcastManager.getInstance(rc).registerReceiver(
            notificationReceiver,
            IntentFilter(NotificationRouter.BROADCAST),
        )

        rc.addActivityEventListener(activityListener)
        handleNotificationIntent(rc.currentActivity?.intent)
    }

    @ReactMethod
    fun init(apiKey: String, options: ReadableMap, promise: Promise) {
        boot(apiKey, options.getString("environment") ?: "STAGING", options, promise)
    }

    @ReactMethod
    fun boot(apiKey: String, environment: String, options: ReadableMap, promise: Promise) {
        try {
            val env = runCatching { Environment.valueOf(environment) }
                .getOrDefault(Environment.STAGING)
            val tags = mutableListOf<String>()
            options.getArray("segmentationTags")?.let { arr ->
                for (i in 0 until arr.size()) {
                    arr.getString(i)?.trim()?.takeIf { it.isNotEmpty() }?.let(tags::add)
                }
            }
            val poll = if (options.hasKey("geoPollIntervalMs")) options.getDouble("geoPollIntervalMs").toLong() else 300_000L
            val distance = if (options.hasKey("defaultDistance")) options.getInt("defaultDistance") else 25

            TenantConfigStore.save(rc, apiKey, env)

            val didInit = BubblInitManager.ensureInit(
                rc.applicationContext as Application,
                BubblConfig(
                    apiKey = apiKey.trim(),
                    environment = env,
                    segmentationTags = tags,
                    geoPollInterval = poll,
                    defaultDistance = distance,
                ),
            )

            ensureNotificationBridge()

            promise.resolve(Arguments.createMap().apply {
                putBoolean("initializedNow", didInit)
                putBoolean("alreadyInitialized", !didInit)
            })
        } catch (t: Throwable) {
            promise.reject("BUBBL_BOOT_FAILED", t.message, t)
        }
    }

    @ReactMethod
    fun requiredPermissions(promise: Promise) {
        val arr = Arguments.createArray()
        arr.pushString(Manifest.permission.ACCESS_FINE_LOCATION)
        arr.pushString(Manifest.permission.ACCESS_COARSE_LOCATION)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            arr.pushString(Manifest.permission.POST_NOTIFICATIONS)
        }
        promise.resolve(arr)
    }

    @ReactMethod
    fun locationGranted(promise: Promise) {
        val fine = ContextCompat.checkSelfPermission(rc, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
        val coarse = ContextCompat.checkSelfPermission(rc, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
        promise.resolve(fine || coarse)
    }

    @ReactMethod
    fun notificationGranted(promise: Promise) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
            promise.resolve(true)
            return
        }
        val granted = ContextCompat.checkSelfPermission(rc, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
        promise.resolve(granted)
    }

    @ReactMethod
    fun requestPushPermission(promise: Promise) {
        promise.resolve(true) // Android push permission is handled via requiredPermissions()
    }

    @ReactMethod
    fun startLocationTracking(promise: Promise) {
        if (!requireInitialized(promise, "startLocationTracking")) return
        runCatching {
            BubblSdk.startLocationTracking(rc)
            promise.resolve(true)
        }.getOrElse {
            promise.reject("BUBBL_START_LOCATION_FAILED", it.message, it)
        }
    }

    @ReactMethod
    fun refreshGeofence(lat: Double, lng: Double) {
        if (!BubblInitManager.isInitialized()) return
        BubblSdk.refreshGeofence(lat, lng)
    }

    @ReactMethod
    fun startGeofenceUpdates() {
        if (!BubblInitManager.isInitialized() || geofenceJob != null) return

        geofenceJob = scope.launch {
            BubblSdk.geofenceFlow.collect { snap ->
                snap ?: return@collect
                val payload = Arguments.createMap().apply {
                    putMap("stats", Arguments.createMap().apply {
                        putInt("campaignsTotal", snap.stats.campaignsTotal)
                        putInt("polygonsTotal", snap.stats.polygonsTotal)
                    })

                    val polygons = Arguments.createArray()
                    snap.polygons.forEach { polygon ->
                        val polygonMap = Arguments.createMap().apply {
                            putInt("campaignId", polygon.campaignId)
                            putString("campaignName", polygon.campaignName)

                            val vertices = Arguments.createArray()
                            polygon.vertices.forEach { vertex ->
                                vertices.pushMap(Arguments.createMap().apply {
                                    putDouble("latitude", vertex.latitude)
                                    putDouble("longitude", vertex.longitude)
                                })
                            }
                            putArray("vertices", vertices)
                        }
                        polygons.pushMap(polygonMap)
                    }

                    putArray("polygons", polygons)
                    putArray("circles", Arguments.createArray())
                }

                emitEvent("bubbl_geofence", payload)
            }
        }
    }

    @ReactMethod
    fun stopGeofenceUpdates() {
        geofenceJob?.cancel()
        geofenceJob = null
    }

    @ReactMethod
    fun hasCampaigns(promise: Promise) {
        if (!requireInitialized(promise, "hasCampaigns")) return
        promise.resolve(BubblSdk.hasCampaigns())
    }

    @ReactMethod
    fun getCampaignCount(promise: Promise) {
        if (!requireInitialized(promise, "getCampaignCount")) return
        promise.resolve(BubblSdk.getCampaignCount())
    }

    @ReactMethod
    fun forceRefreshCampaigns(promise: Promise) {
        if (!requireInitialized(promise, "forceRefreshCampaigns")) return
        BubblSdk.forceRefreshCampaigns()
        promise.resolve(true)
    }

    @ReactMethod
    fun clearCachedCampaigns() {
        if (!BubblInitManager.isInitialized()) return
        BubblSdk.clearCachedCampaigns()
    }

    @ReactMethod
    fun updateSegments(segmentations: ReadableArray, promise: Promise) {
        if (!requireInitialized(promise, "updateSegments")) return

        val tags = (0 until segmentations.size())
            .mapNotNull { segmentations.getString(it)?.trim() }
            .filter { it.isNotEmpty() }

        BubblSdk.updateSegments(tags) { ok ->
            if (ok) promise.resolve(true)
            else promise.reject("BUBBL_SEGMENTS_FAILED", "updateSegments failed")
        }
    }

    @ReactMethod
    fun getCurrentConfiguration(promise: Promise) {
        val config = BubblSdk.getCurrentConfiguration()
        if (config == null) {
            promise.resolve(null)
            return
        }

        promise.resolve(Arguments.createMap().apply {
            putInt("notificationsCount", config.notificationsCount)
            putInt("daysCount", config.daysCount)
            putInt("batteryCount", config.batteryCount)
            putString("privacyText", config.privacyText)
        })
    }

    @ReactMethod
    fun getPrivacyText(promise: Promise) {
        promise.resolve(BubblSdk.getPrivacyText())
    }

    @ReactMethod
    fun refreshPrivacyText(promise: Promise) {
        BubblSdk.refreshPrivacyText { text ->
            if (text != null) promise.resolve(text)
            else promise.reject("BUBBL_PRIVACY_FAILED", "refreshPrivacyText failed")
        }
    }

    @ReactMethod
    fun sendEvent(
        curatedNotificationID: String,
        locationID: String,
        type: String,
        activity: String,
        latitude: Double,
        longitude: Double,
        promise: Promise,
    ) {
        if (!requireInitialized(promise, "sendEvent")) return
        BubblSdk.sendEvent(curatedNotificationID, locationID, type, activity, latitude, longitude) { ok ->
            promise.resolve(ok)
        }
    }

    @ReactMethod
    fun cta(notificationId: Int, locationId: String) {
        if (!BubblInitManager.isInitialized()) return
        BubblSdk.cta(notificationId, locationId)
    }

    @ReactMethod
    fun trackSurveyEvent(notificationId: String, locationId: String, activity: String, promise: Promise) {
        if (!requireInitialized(promise, "trackSurveyEvent")) return
        BubblSdk.trackSurveyEvent(notificationId, locationId, activity) { success ->
            promise.resolve(success)
        }
    }

    @ReactMethod
    fun submitSurveyResponse(notificationId: String, locationId: String, answers: ReadableArray, promise: Promise) {
        if (!requireInitialized(promise, "submitSurveyResponse")) return

        try {
            val parsed = mutableListOf<SurveyAnswer>()
            for (i in 0 until answers.size()) {
                val m = answers.getMap(i) ?: continue
                val choices = if (m.hasKey("choice") && !m.isNull("choice")) {
                    val arr = m.getArray("choice")
                    val out = mutableListOf<ChoiceSelection>()
                    if (arr != null) {
                        for (j in 0 until arr.size()) {
                            val c = arr.getMap(j) ?: continue
                            if (c.hasKey("choice_id") && !c.isNull("choice_id")) {
                                out.add(ChoiceSelection(choice_id = c.getInt("choice_id")))
                            }
                        }
                    }
                    out
                } else null

                parsed.add(
                    SurveyAnswer(
                        question_id = m.getInt("question_id"),
                        type = m.getString("type") ?: "",
                        value = m.getString("value") ?: "",
                        choice = choices,
                    ),
                )
            }

            BubblSdk.submitSurveyResponse(notificationId, locationId, parsed) { success ->
                promise.resolve(success)
            }
        } catch (t: Throwable) {
            promise.reject("BUBBL_SURVEY_SUBMIT_FAILED", t.message, t)
        }
    }

    @ReactMethod
    fun getApiKey(promise: Promise) {
        promise.resolve(BubblSdk.getApiKey)
    }

    @ReactMethod
    fun sayHello(promise: Promise) {
        promise.resolve(BubblSdk.sayHello())
    }

    @ReactMethod
    fun getDeviceLogStreamInfo(promise: Promise) {
        promise.resolve(Arguments.createMap().apply {
            putString("deviceType", "android")
            putString("deviceId", "unsupported")
            putString("deviceIdSuffix", "-----")
        })
    }

    @ReactMethod
    fun getDeviceLogTail(maxLines: Int, promise: Promise) {
        promise.resolve(Arguments.createArray())
    }

    @ReactMethod
    fun startDeviceLogStream(options: ReadableMap, promise: Promise) {
        promise.resolve(Arguments.createMap().apply {
            putBoolean("started", false)
            putString("reason", "not_implemented")
            putString("deviceIdSuffix", "-----")
        })
    }

    @ReactMethod
    fun stopDeviceLogStream() {}

    @ReactMethod
    fun testNotification(promise: Promise) {
        promise.reject("BUBBL_TEST_NOTIFICATION_UNSUPPORTED", "Implement local test notification in your app if needed.")
    }

    override fun invalidate() {
        runCatching {
            if (notificationBridgeRegistered) {
                LocalBroadcastManager.getInstance(rc).unregisterReceiver(notificationReceiver)
                rc.removeActivityEventListener(activityListener)
                notificationBridgeRegistered = false
            }
        }
        geofenceJob?.cancel()
        geofenceJob = null
        scope.cancel()
        super.invalidate()
    }
}

4) Native Registration

In your MainApplication.kt, register the BubblPackage. This is the step that exposes the native methods to your JavaScript code.

override val reactHost: ReactHost by lazy {
  getDefaultReactHost(
    context = applicationContext,
    packageList = PackageList(this).packages.apply {
      add(BubblPackage()) // Register the bridge
    }
  )
}

5) Optional: Bootstrap SDK from Stored Tenant

To ensure Bubbl can track location immediately upon a background process start (even if the React Native bridge hasn't loaded yet), initialize the SDK in your onCreate()

override fun onCreate() {
  super.onCreate()

  if (FirebaseApp.getApps(this).isEmpty()) {
    FirebaseApp.initializeApp(this)
  }

  TenantConfigStore.load(this)?.let { cfg ->
    BubblInitManager.ensureInit(this, TenantConfigStore.toBubblConfig(cfg))
  }
}

After this page, move to Method Reference and Usage Examples.