BubblDocs

Bubbl Android SDK – Developer Reference & Integration Guide

1. Introduction

The Bubbl Android SDK is a powerful geolocation and campaign engagement engine designed to be embedded in native Android applications. It provides low-latency geofence monitoring and contextually aware campaign delivery by leveraging system-level location services and background processing. With its plug-and-play architecture, Bubbl makes it easy for Android developers to integrate proximity-triggered campaigns and events into their mobile applications.

The SDK monitors user activity against a set of predefined geofences (virtual perimeters around geographic zones) and can trigger enter/exit events programmatically. These events can result in direct callback execution within your app or can be paired with Firebase Cloud Messaging (FCM) to deliver location-based push notifications. Additionally, Bubbl enables segmentation of users, allowing product managers and marketing teams to fine-tune campaign delivery based on real-time behavioural data.

Core Features

  • Geofence enter/exit detection: Background geofence monitoring compliant with Android 10+ restrictions. Uses FusedLocationProviderClient for efficient geolocation.
  • Campaign segmentation
  • Push notification support: Integration with Firebase Push Notifications (FCM)
  • Logging and diagnostics
  • Reactive state publishers for authorization tracking
  • Granular permission handling tailored for Android runtime permissions
  • Lightweight footprint, built for performance in production apps
  • Publisher APIs via Kotlin Flows or LiveData (if wrapped)

Ideal Use Cases

  • Context-triggered marketing (e.g., "Welcome to our store")
  • Site-specific engagement (retail, events, real estate tours)
  • Dynamic content based on user's real-world behaviour
  • Geo-analytics and behavioural insights
  • Personalized campaigns based on travel or routine detection

Getting Started

📋 Prerequisites: The Bubbl SDK uses a host-scoped dependency model. Your app must provide Firebase, Play Services, and other dependencies.
  1. Android Studio: Narwhal (2025.1.1+) or newer
  2. Min SDK: 27 (not 23 - this is the actual requirement)
  3. Compile & Target SDK: 34+ (Android 14+)
  4. Java Version: 11+ (required for SDK compatibility)
  5. Kotlin Version: 1.8+ (recommended: 2.0+)
  6. Firebase BOM: MANDATORY - prevents version conflicts and crashes
  7. Firebase Setup: Firebase project configured with google-services.json
  8. Required Host Dependencies: Firebase Messaging, Play Services Location/Maps, WorkManager
  9. Critical Permission: FOREGROUND_SERVICE_LOCATION (Android 14+)
  10. Standard Permissions: ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION, POST_NOTIFICATIONS
⚠️ Important: Review section 2.3 for complete dependency requirements before starting integration.
📘 Objective Integrate bubbl-sdk into an Android app without any extra Gradle plugin.
📘 Audience Android developers (Kotlin / Java)

2. SDK Integration

2.1 Via Gradle (Recommended)

The easiest way to integrate Bubbl SDK is through Gradle dependency management. This method automatically handles version updates and dependency resolution.

Method A: GitHub Packages (Recommended)

Add the Bubbl repository to your project-level settings.gradle.kts:


dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url = uri("https://jitpack.io") }
        maven {
            url = uri("https://maven.pkg.github.com/bubbl-tech-sdk/android-sdk")
            // If needed use below
            credentials {
                username = "YOUR_USERNAME"
                password = "YOUR_API_TOKEN"
            }
        }
    }
}

Add Dependency

Add the Bubbl SDK dependency to your app-level build.gradle.kts:


dependencies {
    // Choose the appropriate dependency based on your repository method:

    // Method A: GitHub Packages
    implementation("tech.bubbl:bubbl-sdk:2.0.3")

    // Required dependencies (if not already included)
    implementation("com.google.android.gms:play-services-location:21.2.0")
    implementation("com.google.android.gms:play-services-maps:19.1.0")
    implementation("com.google.firebase:firebase-messaging-ktx:24.0.0")
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1")
}
Note: Contact Bubbl support to determine which repository method is available for your organization and to get the appropriate credentials.
Alternative: If you don't have access to any of the Gradle repositories, you can use the manual AAR integration method below.

Additional Gradle Integration Options

Version Catalogs (Gradle 7.0+)

For better dependency management, use version catalogs in gradle/libs.versions.toml:


[versions]
bubbl-sdk = "2.0.3"
play-services-location = "21.2.0"
play-services-maps = "19.1.0"
firebase-messaging = "24.0.0"
core-ktx = "1.13.1"
lifecycle-runtime = "2.8.1"

[libraries]
bubbl-sdk = { group = "tech.bubbl", name = "bubbl-sdk", version.ref = "bubbl-sdk" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "play-services-location" }
play-services-maps = { group = "com.google.android.gms", name = "play-services-maps", version.ref = "play-services-maps" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx", version.ref = "firebase-messaging" }
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime" }

[plugins]
android-application = { id = "com.android.application", version = "8.1.0" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version = "1.8.0" }

Then in your build.gradle.kts:


dependencies {
    implementation(libs.bubbl.sdk)
    implementation(libs.play.services.location)
    implementation(libs.play.services.maps)
    implementation(libs.firebase.messaging)
    implementation(libs.core.ktx)
    implementation(libs.lifecycle.runtime)
}
Build Variants

Configure different SDK configurations for debug/release builds:


android {
    buildTypes {
        debug {
            buildConfigField("String", "BUBBL_ENVIRONMENT", ""STAGING"")
            buildConfigField("String", "BUBBL_API_KEY", ""YOUR_STAGING_API_KEY"")
        }
        release {
            buildConfigField("String", "BUBBL_ENVIRONMENT", ""PRODUCTION"")
            buildConfigField("String", "BUBBL_API_KEY", ""YOUR_PRODUCTION_API_KEY"")
        }
    }
}
Groovy DSL Support

If you are using Groovy DSL instead of Kotlin DSL:


// settings.gradle
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven {
            url "https://maven.pkg.github.com/bubbl-tech-sdk/android-sdk"
            credentials {
                username = "YOUR_GITHUB_USERNAME"
                password = "YOUR_GITHUB_TOKEN"
            }
        }
    }
}

// app/build.gradle
dependencies {
    implementation "tech.bubbl:bubbl-sdk:2.0.3"
    implementation "com.google.android.gms:play-services-location:21.2.0"
    implementation "com.google.firebase:firebase-messaging-ktx:24.0.0"
}
Multi-Module Projects

For projects with multiple modules, add the repository to the root settings.gradle.ktsand declare the dependency only in the app module that needs it:


// settings.gradle.kts (root)
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven {
            url = uri("https://maven.pkg.github.com/bubbl-tech-sdk/android-sdk")
            credentials {
                username = "YOUR_GITHUB_USERNAME"
                password = "YOUR_GITHUB_TOKEN"
            }
        }
    }
}

// app/build.gradle.kts (only in the app module that needs Bubbl)
dependencies {
    implementation("tech.bubbl:bubbl-sdk:2.0.3")
    // ... other dependencies
}

2.2 Manual .aar drop-in

  1. Download bubbl-sdk-2.0.3.aar (2.1 currently)  here.
  2. Copy it into app/libs/.
  3. Add to build.gradle.kts (Kotlin DSL):
    
    repositories { flatDir { dirs("libs") } }
    
    dependencies {
        implementation(files("libs/bubbl-sdk-2.0.3.aar"))
    }
                  
    Using Groovy DSL? Replace the dependency line with
    implementation(name: "bubbl-sdk-2.0.3", ext: "aar")

2.3 Dependency Management & Host Requirements

⚠️ Critical: The Bubbl SDK follows a host-scoped dependency model. Your app must provide the required dependencies listed below. Failure to use the Firebase BOM will cause runtime crashes.

Required Host Dependencies

The following dependencies must be included in your host application. The SDK usescompileOnly for these to avoid version conflicts:

🚨 Firebase BOM is MANDATORY: You must use the Firebase BOM to prevent version conflicts that cause IllegalStateException andIncompatibleClassChangeError crashes.

dependencies {
    // Firebase BOM (MANDATORY - prevents version conflicts and crashes)
    implementation(platform("com.google.firebase:firebase-bom:33.1.0"))

    // Firebase dependencies (no version numbers - BOM manages them)
    implementation("com.google.firebase:firebase-messaging-ktx")
    implementation("com.google.firebase:firebase-analytics-ktx")

    // Google Play Services (REQUIRED - versions aligned with Firebase BOM)
    implementation("com.google.android.gms:play-services-location:21.2.0")
    implementation("com.google.android.gms:play-services-maps:19.1.0")

    // AndroidX Work Manager (REQUIRED for background tasks)
    implementation("androidx.work:work-runtime-ktx:2.9.0")

    // Standard AndroidX dependencies (usually already present)
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1")

    // Bubbl SDK
    implementation("tech.bubbl:bubbl-sdk:2.0.3")
}

Version Compatibility

Minimum supported versions:

  • Firebase BOM: 32.0.0+ (recommended: 33.1.0+)
  • Google Play Services Location: 21.0.0+
  • Google Play Services Maps: 18.0.0+
  • AndroidX Work Manager: 2.8.0+
Why Host-Scoped? This approach prevents version conflicts between the SDK and your existing dependencies. You maintain control over the exact versions used in your app.

Dependency Conflict Resolution

Common Issue: Dependency version conflicts are the #1 cause of integration failures. These manifest as runtime crashes, even when your code looks correct.

Typical Error Patterns:
  • IllegalStateException: FirebaseApp is not initialized
  • IncompatibleClassChangeError with Google Play Services
  • NoSuchMethodError or ClassNotFoundException

Solution: Always use the Firebase BOM and avoid specifying versions for Firebase/Play Services:


// ❌ WRONG - Will cause version conflicts
dependencies {
    implementation("com.google.firebase:firebase-messaging:23.1.0")
    implementation("com.google.firebase:firebase-core:21.0.0")
    implementation("com.google.android.gms:play-services-location:20.0.0")
}

// ✅ CORRECT - BOM ensures version compatibility
dependencies {
    implementation(platform("com.google.firebase:firebase-bom:33.1.0"))
    implementation("com.google.firebase:firebase-messaging") // No version
    implementation("com.google.android.gms:play-services-location:21.2.0")
}
          

Dependency Verification

The SDK performs runtime verification of required dependencies during initialization. If any required dependency is missing, you'll see a clear error message:


IllegalStateException: Missing required dependency: com.google.firebase.messaging.FirebaseMessaging
Please add Firebase Messaging to your app dependencies.
          

3. Permissions & Manifest

🚨 Critical for Android 14+: If your app targets API 34+ (Android 14), you MUST include FOREGROUND_SERVICE_LOCATION permission or your app will crash at runtime with a SecurityException.

Ensure the following are merged into yourAndroidManifest.xml:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools">

  <!-- permissions -->
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
  <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
  <!-- CRITICAL: Required for Android 14+ (API 34+) when targeting modern SDKs -->
  <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
  <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>

  <application … >
      <!-- main activity -->
      …
      <!-- Bubbl foreground service -->
      <service
          android:name="tech.bubbl.sdk.services.LocationUpdatesService"
          android:foregroundServiceType="location" />

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

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

      <!-- FCM Default Notification Channel -->
      <meta-data
          android:name="com.google.firebase.messaging.default_notification_channel_id"
          android:value="bubbl_push" />
      
      <!-- Optional: Default Notification Icon -->
      <meta-data
          android:name="com.google.firebase.messaging.default_notification_icon"
          android:resource="@drawable/ic_launcher_foreground" />

      <!-- File Provider for log sharing -->
      <provider
          android:name="androidx.core.content.FileProvider"
          android:authorities="${applicationId}.fileprovider"
          android:exported="false"
          android:grantUriPermissions="true">
          <meta-data
              android:name="android.support.FILE_PROVIDER_PATHS"
              android:resource="@xml/file_paths" />
      </provider>
  </application>
</manifest>
On Android 13 (API 33)+ youmust prompt for POST_NOTIFICATIONS at runtime.

Ensure your google-services.json is included in the app/ directory.

Note: FOREGROUND_SERVICE_LOCATION is required for apps targeting Android 10 (API 29) and above when using foreground services for continuous location tracking. If Bubbl internally relies on such services for geofencing, this permission must be declared to avoid runtime failures.

Recommended Application Tag Configuration:

<application
  android.name=".MyApp" // TODO: ensure you replace this with your class
  ...>               
</application>

After declaring permissions and adding the SDK dependency, try to build the application to ensure all dependencies are resolved correctly and no compile-time issues exist.

4. Initialisation & Lifecycle

Initialisation is critical to ensure the Bubbl SDK sets up internal services such as location listeners, campaign polling, and push notification routing. This step must occur as early as possible—ideally in the Application class—before any geofence detection or segmentation is attempted.

a. Create a Custom Application Class

If you don't already have one, create a custom `Application` class—for instance, to initialise Firebase—and initialise Bubbl there. This is the most robust option because it ensures SDK setup happens before any Activity or Fragment is launched.

package com.example.bubbltest

import android.app.Application
import com.google.firebase.FirebaseApp
import tech.bubbl.sdk.BubblSdk
import tech.bubbl.sdk.config.BubblConfig
import tech.bubbl.sdk.config.Environment

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // initialize Firebase first
        FirebaseApp.initializeApp(this)

        // then initialize Bubbl
        BubblSdk.init(
            application = this,
            config = BubblConfig(
                apiKey          = "YOUR_API_KEY",
                environment     = Environment.STAGING,
                segmentationTags = listOf("default"),
                geoPollInterval = 5 * 60_000L,
                defaultDistance = 25
            )
        )
    }
}

b. Using the MainActivity Class

If you don't have a custom `Application` class, you can alternatively initialise Bubbl from your main Activity after confirming that all required permissions are granted. This works too, but may delay SDK setup slightly and should ensure no location or notification logic fires before initialisation completes.

Tip: start location tracking here once permissions are granted.

class MainActivity : ComponentActivity(), OnMapReadyCallback {

    private val permMgr by lazy { PermissionManager(this) }
    private lateinit var mapView: MapView
    private lateinit var googleMap: GoogleMap

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        mapView = findViewById(R.id.map_view)
        mapView.onCreate(savedInstanceState)

        val launcher = permMgr.registerLauncher { granted ->
            if (granted) bootSdk()
        }
        launcher.launch(permMgr.requiredPermissions())
    }

    private fun bootSdk() {
        BubblSdk.init(
            application = application as Application,
            config = BubblConfig(
                apiKey          = "YOUR_API_KEY",
                environment     = Environment.STAGING, // .STAGING, .PRODUCTION
                geoPollInterval = 5 * 60_000L, //minutes
                defaultDistance = 25           //meters
            )
        )
        BubblSdk.startLocationTracking(this)
        mapView.getMapAsync(this)
    }

    override fun onMapReady(map: GoogleMap) {
        googleMap = map.apply { uiSettings.isMyLocationButtonEnabled = true }
        lifecycleScope.launch {
            BubblSdk.geofenceFlow.collectLatest { snap ->
                snap ?: return@collectLatest
                googleMap.clear()
                snap.polygons.forEach { p ->
                    googleMap.addPolygon(
                        PolygonOptions()
                          .addAll(p.vertices)
                          .strokeColor(0xFF1976D2.toInt())
                          .fillColor(0x331976D2)
                    )
                }
            }
        }
    }
}

5. Public API Reference

init(application: Application, config: BubblConfig)

What it does: Boots up all SDK internals—location monitoring, geofence polling, FCM binding, campaign delivery, etc.
When to call: As early as possible, ideally in your custom Application.onCreate().

// MyApplication.kt
class MyApplication : Application() {
  override fun onCreate() {
    super.onCreate()
    // 1️⃣ Initialize Firebase (if needed)
    FirebaseApp.initializeApp(this)

    // 2️⃣ Initialize Bubbl
    BubblSdk.init(
      application = this,
      config = BubblConfig(
        apiKey           = "YOUR_API_KEY",           // your API key
        environment      = Environment.STAGING,       // STAGING or PRODUCTION
        segmentationTags = listOf("beta_users"),     // optional segments
        geoPollInterval  = 5 * 60_000L,              // poll every 5 minutes
        defaultDistance  = 25                         // default geofence radius in meters
      )
    )
  }
}
startLocationTracking(context: Context)

What it does: Starts fused-location updates and (on Android 10+) a foreground service.
When to call: After init() and once all required permissions are granted.

// In an Activity/Fragment
if (BubblSdk.locationGranted()) {
  BubblSdk.startLocationTracking(this)
}
refreshGeofence(latitude: Double, longitude: Double)

What it does: Manually fetches geofences near the provided coordinates.
When to call: After manual location changes (map click, custom GPS), in onResume(), or right after login.

// Example: map click listener
map.setOnMapClickListener { latLng ->
  BubblSdk.refreshGeofence(latLng.latitude, latLng.longitude)
}
updateSegments(
  segmentations: List<String>,
  callback: ((Boolean) -> Unit)? = null
)

What it does: Updates device segmentation tags dynamically after initialization. This allows changing 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 init() before calling this method.

Example 1: Update segments when user subscribes to premium

// When user upgrades to premium subscription
fun onUserUpgradedToPremium() {
    BubblSdk.updateSegments(
        segmentations = listOf("premium", "subscribed")
    ) { success ->
        if (success) {
            Log.d("Segments", "✅ Segments updated successfully")
        } else {
            Log.e("Segments", "❌ Failed to update segments")
        }
    }
}

Example 2: Update segments based on user location region

// When user changes region
fun onUserRegionChanged(region: String) {
    val newSegments = listOf("region_$region", "active_user")
    BubblSdk.updateSegments(newSegments) { success ->
        Log.d("Segments", "Region segments updated: $success")
    }
}

Example 3: Update segments without callback

// Fire and forget - useful for non-critical segment updates
fun updateUserPreferences() {
    BubblSdk.updateSegments(listOf("newsletter_subscriber", "loyalty_member"))
}

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.
getCurrentConfiguration(): Configuration?

What it does: Returns the currently cached configuration without making a network call. Returns null if no configuration has been fetched yet.
When to call: When you need to access configuration data (privacy text, notification limits, etc.) without triggering a network request.
Important: This returns cached data. Use refreshPrivacyText() to force a refresh.

Example: Accessing configuration data

val config = BubblSdk.getCurrentConfiguration()
if (config != null) {
    Log.d("Config", "Privacy Text: ${config.privacyText}")
    Log.d("Config", "Max Notifications: ${config.notificationsCount}")
    Log.d("Config", "Days Count: ${config.daysCount}")
    Log.d("Config", "Battery Count: ${config.batteryCount}")
} else {
    Log.w("Config", "No configuration available yet")
}
getPrivacyText(): String

What it does: Returns the cached privacy text without making a network call. Returns empty string if no configuration has been fetched yet.
When to call: When displaying privacy information to users in your UI.
Use case: Display privacy disclaimers in settings, onboarding, or permission screens.

Example: Display privacy text in UI

// In your Activity or Fragment
val privacyText = BubblSdk.getPrivacyText()
if (privacyText.isNotEmpty()) {
    privacyTextView.text = privacyText
    privacyTextView.visibility = View.VISIBLE
} else {
    privacyTextView.visibility = View.GONE
}

Example: Show in settings screen

class SettingsFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Display privacy policy from Bubbl config
        val privacyText = BubblSdk.getPrivacyText()
        binding.privacyPolicyText.text = privacyText
    }
}
refreshPrivacyText(
  callback: ((String?) -> Unit)? = null
)

What it does: Refreshes the configuration from the backend and returns the updated privacy text. This makes a network call to fetch the latest configuration.
When to call: When you need to ensure you have the latest privacy text from the server, such as when displaying legal information.
Callback: Receives the privacy text string on success, or null on failure.

Example: Refresh and display latest privacy text

// Show loading indicator
progressBar.visibility = View.VISIBLE

BubblSdk.refreshPrivacyText { privacyText ->
    progressBar.visibility = View.GONE

    if (privacyText != null) {
        privacyTextView.text = privacyText
        Log.d("Privacy", "✅ Privacy text updated: $privacyText")
    } else {
        Log.e("Privacy", "❌ Failed to refresh privacy text")
        Toast.makeText(this, "Failed to load privacy text", Toast.LENGTH_SHORT).show()
    }
}

Example: Refresh on settings screen load

class PrivacyPolicyActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_privacy)

        // Show cached text immediately
        binding.privacyText.text = BubblSdk.getPrivacyText()

        // Refresh to get latest version
        BubblSdk.refreshPrivacyText { latestText ->
            if (latestText != null) {
                binding.privacyText.text = latestText
            }
        }
    }
}
💡 Best Practice: Use getPrivacyText() for immediate display of cached text, then call refreshPrivacyText() in the background to ensure users see the most up-to-date privacy information from your Bubbl dashboard.
geofenceFlow: Flow<GeofenceSnapshot?>

What it is: Hot StateFlow emitting the latestGeofenceSnapshot whenever geofences load or transitions occur.
How to use: Collect in a coroutine (e.g.lifecycleScope) to update your UI.

// Example: drawing polygons
lifecycleScope.launch {
  BubblSdk.geofenceFlow.collectLatest { snap ->
    snap?.polygons?.forEach { poly ->
      googleMap.addPolygon(
        PolygonOptions().addAll(poly.vertices)
      )
    }
  }
}
data class GeofenceSnapshot(
  polygons: List<PolygonData>,
  stats: GeofenceStats,
  campaignId: String?
)

What it holds:
polygons: List of fence shapes (vertices)
stats: entry/exit counts and timestamps
campaignId: active campaign, if any

data class NotificationPayload(
  campaignId: String,
  title: String,
  body: String,
  imageUrl: String?,
  actions: List<PayloadAction>
)

What it models: Rich push content delivered via FCM + Bubbl (media URLs, titles, action metadata).

enum class Environment { STAGING, PRODUCTION }

What it does: Switches base-URL and feature flags between staging vs. production servers.

data class BubblConfig(
  apiKey: String,
  environment: Environment,
  segmentationTags: List<String> = emptyList(),
  geoPollInterval: Long = 60_000L,
  defaultDistance: Int = 50
)

What it holds: All tunables supplied toinit() (API key, environment, polling interval, radius, tags).

class PermissionManager(activity: ComponentActivity) {
  fun registerLauncher(callback: (Boolean)->Unit): ActivityResultLauncher<Array<String>>
  fun locationGranted(): Boolean
  fun notificationGranted(): Boolean
}

What it does: Wraps Android's permission APIs to
• Register a launcher for all required permissions
• Check if location/notification permissions are granted

// Example setup
val permMgr = PermissionManager(this)
val launcher = permMgr.registerLauncher { granted ->
  if (granted) bootSdk()
}
launcher.launch(permMgr.requiredPermissions())
cta(nId: Int, locationId: String)

What it does: Tracks call-to-action engagement when users tap notification CTA buttons.
When to call: When user taps on a CTA button in a push notification to track engagement analytics.

// Track CTA engagement
BubblSdk.cta(
    nId = notificationId,
    locationId = triggerLocationId
)

5.1 UI Components & Internal Resources

⚠️ Important: The SDK contains internal UI components that arenot intended for direct use by integrating applications.

Internal UI Components

The Bubbl SDK includes several UI components for internal functionality:

  • Notification Modal Fragments: Rich notification display
  • Permission Dialog Activities: Location permission flows
  • Debug/Diagnostic Screens: SDK troubleshooting tools

Resource Naming Convention

All SDK internal resources are prefixed with bubbl_ to prevent conflicts with your app resources:


<!-- Internal SDK layouts (DO NOT USE) -->
bubbl_notification_modal.xml
bubbl_permission_dialog.xml
bubbl_debug_screen.xml

<!-- Internal SDK drawable resources -->
bubbl_ic_location.xml
bubbl_bg_modal.xml

<!-- Internal SDK string resources -->
bubbl_permission_rationale
bubbl_notification_title
          

Usage Guidelines

❌ Do NOT:
  • Reference SDK layouts directly in your app
  • Override or customize SDK UI resources
  • Extend SDK Activity or Fragment classes
  • Modify SDK drawable or string resources
✅ Instead:
  • Use the provided public APIs only
  • Customize notification appearance via Firebase Console
  • Handle SDK callbacks in your own UI components
  • Create your own custom dialogs/screens as needed

Custom UI Implementation

If you need custom UI for location permissions or notifications, implement your own components and use the SDK's callback APIs:


// Example: Custom permission handling
class CustomPermissionActivity : ComponentActivity() {
    private val permissionManager = PermissionManager(this)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Your custom UI implementation
        setContentView(R.layout.activity_custom_permission)

        // Use SDK's permission utilities
        val launcher = permissionManager.registerLauncher { granted ->
            if (granted) {
                // Start SDK with your custom UI feedback
                showCustomSuccessMessage()
                BubblSdk.startLocationTracking(this)
            } else {
                showCustomPermissionDeniedDialog()
            }
        }
    }
}

// Example: Custom notification handling
BubblSdk.geofenceFlow.collectLatest { snapshot ->
    snapshot?.campaignId?.let { campaignId ->
        // Display your custom UI instead of SDK's modal
        showCustomCampaignDialog(campaignId)
    }
}
          

Resource Conflicts Prevention

To avoid any potential resource conflicts:

  1. Never name your resources starting with bubbl_
  2. Use your own app's resource naming conventions
  3. Test thoroughly on different device configurations
  4. Monitor for any resource-related build warnings

6. Troubleshooting

📋 Real Integration Issues: These are the most common problems encountered during actual SDK integration, based on developer feedback.

Critical Runtime Crashes

  • 🚨 App crashes on Android 14+ with SecurityException
    Error: Foreground service type location not in manifest
    Root Cause: Missing FOREGROUND_SERVICE_LOCATION permission for targetSdk 34+
    Solution: Add to your AndroidManifest.xml:
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
  • 🚨 "IllegalStateException: FirebaseApp is not initialized"
    Error: App crashes immediately on launch after SDK initialization
    Root Cause: Firebase version conflicts preventing proper initialization
    Solution: Use Firebase BOM to align all Firebase library versions:
    
    dependencies {
        implementation(platform("com.google.firebase:firebase-bom:33.1.0"))
        implementation("com.google.firebase:firebase-messaging") // No version!
    }
  • 🚨 "IncompatibleClassChangeError" with Google Play Services
    Error: IncompatibleClassChangeError: com.google.android.gms.location.FusedLocationProviderClient
    Root Cause: Version mismatch between SDK's compiled version and runtime version
    Solution: Ensure Play Services versions are compatible with Firebase BOM

Common Integration Issues

  • Polygons not showing?
    Make sure you are collecting from BubblSdk.geofenceFlow after calling BubblSdk.init(). This ensures the SDK has loaded its state and permissions are granted.
  • Startup crash?
    Double-check the permission flow. Ensure the app has runtime permissions for location and that your google-services.json file is properly placed.
  • FCM token null?
    Verify that Firebase is correctly set up in your project. Double-check your Firebase console and that google-services.json is configured properly.
  • Gradle sync fails with authentication error?
    Ensure you have valid repository credentials from Bubbl support. Check that your username and API token are correctly configured insettings.gradle.kts. If issues persist, use the manual AAR integration method as a fallback.
  • Dependency resolution conflicts?
    The Bubbl SDK may conflict with other location or Firebase libraries. Try excluding conflicting transitive dependencies or update to the latest versions of Google Play Services and Firebase libraries.
  • Build fails with "Could not resolve tech.bubbl:bubbl-sdk"?
    Verify that the GitHub Packages repository URL is correct and accessible. Check your network connection and GitHub credentials. Consider using the manual AAR method if repository access is unavailable.
  • Missing dependency errors during SDK initialization?
    The SDK requires host-provided dependencies. Ensure you've added all required dependencies listed in section 2.3. Use the Firebase BOM to manage Firebase versions automatically. Check that your versions meet the minimum requirements.
  • Resource conflicts or "Duplicate resource" build errors?
    Ensure you're not overriding any SDK resources prefixed with bubbl_. All SDK UI components are internal-only. If you need custom UI, implement your own components using the SDK's public APIs.
  • Version conflicts with Firebase or Play Services?
    The SDK uses compileOnly for external dependencies, so version conflicts should be rare. Ensure you're using compatible versions listed in the dependency management section. Consider using the Firebase BOM for automatic version management.

7. Full Dependency Block (Demo App)

Option A: Gradle Integration Methods

Method A1: Private Maven Repository

// settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven {
            url = uri("https://maven.pkg.github.com/bubbl-tech-sdk/android-sdk")
            credentials {
                username = "YOUR_GITHUB_USERNAME"
                password = "YOUR_GITHUB_TOKEN"
            }
        }
    }
}

// app/build.gradle.kts
dependencies {
    implementation("tech.bubbl:bubbl-sdk:2.0.3")
    implementation("com.google.android.gms:play-services-location:21.2.0")
    implementation("com.google.android.gms:play-services-maps:19.1.0")
    implementation("com.google.firebase:firebase-messaging-ktx:24.0.0")
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1")
}

Method A2: GitHub Packages

// settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven {
            url = uri("https://maven.pkg.github.com/bubbl-tech-sdk/android-sdk")
            credentials {
                username = "YOUR_GITHUB_USERNAME"
                password = "YOUR_GITHUB_TOKEN"
            }
        }
    }
}

// app/build.gradle.kts
dependencies {
    implementation("tech.bubbl:bubbl-sdk:2.0.3")
    implementation("com.google.android.gms:play-services-location:21.2.0")
    implementation("com.google.android.gms:play-services-maps:19.1.0")
    implementation("com.google.firebase:firebase-messaging-ktx:24.0.0")
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1")
}

Method A3: Version Catalogs

// gradle/libs.versions.toml
[versions]
bubbl-sdk = "2.0.3"
play-services-location = "21.2.0"
play-services-maps = "19.1.0"
firebase-messaging = "24.0.0"
core-ktx = "1.13.1"
lifecycle-runtime = "2.8.1"

[libraries]
bubbl-sdk = { group = "tech.bubbl", name = "bubbl-sdk", version.ref = "bubbl-sdk" }
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "play-services-location" }
play-services-maps = { group = "com.google.android.gms", name = "play-services-maps", version.ref = "play-services-maps" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx", version.ref = "firebase-messaging" }
core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime" }

// app/build.gradle.kts
dependencies {
    implementation(libs.bubbl.sdk)
    implementation(libs.play.services.location)
    implementation(libs.play.services.maps)
    implementation(libs.firebase.messaging)
    implementation(libs.core.ktx)
    implementation(libs.lifecycle.runtime)
}

Option B: Manual AAR Integration

repositories { flatDir { dirs("libs") } }

dependencies {
    implementation(files("libs/bubbl-sdk-2.0.3.aar"))
    implementation("com.google.android.gms:play-services-location:21.2.0")
    implementation("com.google.android.gms:play-services-maps:19.1.0")
    implementation("com.google.firebase:firebase-messaging-ktx:24.0.0")
    implementation("androidx.core:core-ktx:1.13.1")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.1")
    // … plus other normal app deps
}

8. Push Notification Configuration & Rich Media

8.1 Enhanced AndroidManifest Configuration

Add these metadata entries for proper FCM handling:

<application>
    <!-- FCM Default Notification Channel -->
    <meta-data
        android:name="com.google.firebase.messaging.default_notification_channel_id"
        android:value="bubbl_push" />
    
    <!-- Optional: Default Notification Icon -->
    <meta-data
        android:name="com.google.firebase.messaging.default_notification_icon"
        android:resource="@drawable/ic_launcher_foreground" />

    <!-- File Provider for log sharing -->
    <provider
        android:name="androidx.core.content.FileProvider"
        android:authorities="${applicationId}.fileprovider"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>
</application>

8.2 FCM Service Implementation Details

The SDK's MyFirebaseMessagingService handles rich notification parsing:

class MyFirebaseMessagingService : FirebaseMessagingService() {
    override fun onMessageReceived(msg: RemoteMessage) {
        val d = msg.data
        
        // Extract rich notification data
        val notificationId = d["notification_id"]?.toIntOrNull() ?: 0
        val type = d["type"] ?: ""
        val mediaUrl = d["media_url"] ?: ""
        val ctaLabel = d["cta_label"]
        val ctaUrl = d["cta_url"]
        
        // Create rich notification DTO
        val dto = PushNotificationDto(
            id = notificationId,
            headline = d["header"] ?: d["friendly_name"] ?: "Notification",
            body = d["body_text"] ?: "New notification",
            activation = "PUSH",
            media = if (mediaUrl.isNotEmpty()) {
                listOf(PushNotificationDto.Media(type, mediaUrl))
            } else emptyList(),
            cta = if (ctaLabel != null && ctaUrl != null) {
                listOf(PushNotificationDto.Cta(ctaLabel, ctaUrl))
            } else emptyList(),
            locationId = d["cId"] ?: "-1"
        )
        
        // Display notification
        NotificationRouter.push(applicationContext, dto, d["cId"] ?: "-1")
    }
}

9. Enhanced SDK Methods

9.1 Campaign Management

hasCampaigns(): Boolean

Check if campaigns are currently loaded in the SDK.

// Check if any campaigns are loaded
if (BubblSdk.hasCampaigns()) {
    // Campaigns are available, proceed with normal flow
    showCampaignUI()
} else {
    // No campaigns loaded, show loading state or refresh
    showLoadingState()
    BubblSdk.forceRefreshCampaigns()
}
getCampaignCount(): Int

Get the number of currently loaded campaigns.

// Get and display campaign count
val count = BubblSdk.getCampaignCount()
campaignStatusTv.text = "Campaigns: $count loaded"

// Update UI based on campaign count
when {
    count == 0 -> showNoCampaignsMessage()
    count < 5 -> showLimitedCampaignsWarning()
    else -> showNormalCampaignUI()
}
forceRefreshCampaigns()

Force refresh campaigns using current or last known location.

// Force refresh on app resume if no campaigns
override fun onResume() {
    super.onResume()
    if (!BubblSdk.hasCampaigns()) {
        BubblSdk.forceRefreshCampaigns()
    }
}

// Force refresh on user action (pull-to-refresh)
refreshButton.setOnClickListener {
    showRefreshingIndicator()
    BubblSdk.forceRefreshCampaigns()
    // Campaigns will be updated via geofenceFlow
}
clearCachedCampaigns()

Clear cached campaigns (useful for testing).

// Clear campaigns for testing different scenarios
fun clearCampaignsForTesting() {
    BubblSdk.clearCachedCampaigns()
    
    // Update UI to reflect empty state
    campaignStatusTv.text = "Campaigns cleared"
    hideAllCampaignUI()
}

// Clear and reload campaigns
fun resetCampaigns() {
    BubblSdk.clearCachedCampaigns()
    BubblSdk.forceRefreshCampaigns()
    showLoadingState()
}

9.2 Usage Examples

// Check campaign status in onResume
override fun onResume() {
    super.onResume()
    if (!BubblSdk.hasCampaigns()) {
        BubblSdk.forceRefreshCampaigns()
    }
    
    // Update UI with campaign count
    val count = BubblSdk.getCampaignCount()
    campaignStatusTv.text = "Campaigns: $count loaded"
}

// Handle notification CTA click
private fun handleCtaClick(notificationId: Int, locationId: String, url: String) {
    // Track engagement
    BubblSdk.cta(notificationId, locationId)
    
    // Open URL
    startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}

10. Logging and Debugging

10.1 FCM Debugging

The FCM service logs comprehensive debugging information:

FcmService: ========== FCM MESSAGE RECEIVED ==========
FcmService: Step 1: Message received from: SENDER_ID
FcmService: Step 2: Data payload size: 8
FcmService: Step 3: Data payload fields:
FcmService:   - cId: campaign_123
FcmService:   - notification_id: 789
FcmService:   - type: image
FcmService:   - media_url: https://example.com/image.jpg
FcmService: Step 4: ✅ RICH NOTIFICATION DATA FOUND!
FcmService: Step 5: ✅ Created rich DTO
FcmService: Step 6: ✅ Rich push notification displayed!

11.1 Rich Notification Modals

When notifications are tapped, the SDK creates rich modal dialogs with:

  • Full-size media display (images/videos)
  • Rich text content
  • Interactive CTA buttons
  • Campaign information

11.2 Intent Handling

Your host app should handle notification taps to display modals:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    // Handle notification intent
    handleNotificationIntent(intent)
}

override fun onNewIntent(intent: Intent) {
    super.onNewIntent(intent)
    handleNotificationIntent(intent)
}

private fun handleNotificationIntent(intent: Intent?) {
    // Extract notification data from intent extras
    val campaignId = intent?.getStringExtra("cId")
    val notificationId = intent?.getStringExtra("notification_id")
    val mediaUrl = intent?.getStringExtra("media_url")
    
    if (campaignId != null || notificationId != null) {
        // Create and show modal with available data
        val domainNotification = NotificationRouter.DomainNotification(
            id = notificationId?.toIntOrNull() ?: 0,
            headline = intent.getStringExtra("header") ?: "Notification",
            body = intent.getStringExtra("body_text") ?: "",
            mediaUrl = mediaUrl,
            mediaType = intent.getStringExtra("type"),
            activation = "PUSH",
            ctaLabel = intent.getStringExtra("cta_label"),
            ctaUrl = intent.getStringExtra("cta_url"),
            locationId = campaignId ?: "-1"
        )
        
        // Show modal
        pendingModal = domainNotification
        maybeShowModal()
    }
}

12. Advanced Troubleshooting

  • Rich Notifications Not Working?
    1. Check FCM payload structure - ensure backend sends data in data payload, not notification payload
    2. Verify field names match exactly (notification_id, media_url, cta_label, etc.)
    3. Check FCM service logs for detailed debugging information
    4. Test with simple payload first before adding media/CTAs
  • Modal Not Showing?
    1. Ensure supportFragmentManager.isStateSaved is false
    2. Modal display is deferred until onResume() lifecycle
    3. Check that notification tap passes correct intent extras
    4. Verify notification permissions are granted
  • Campaign Issues?
    1. Call BubblSdk.forceRefreshCampaigns() to reload empty campaigns
    2. Campaigns require location access - check permissions
    3. Verify internet connectivity for API calls
    4. Confirm correct staging/production API key
  • Debug Logs Not Working?
    1. Ensure FileProvider is configured in AndroidManifest
    2. Check file_paths.xml configuration
    3. Verify external storage permissions if needed
    4. Test log sharing functionality after generating logs