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.