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
- Android Studio: Narwhal (2025.1.1+) or newer
- Min SDK: 27 (not 23 - this is the actual requirement)
- Compile & Target SDK: 34+ (Android 14+)
- Java Version: 11+ (required for SDK compatibility)
- Kotlin Version: 1.8+ (recommended: 2.0+)
- Firebase BOM: MANDATORY - prevents version conflicts and crashes
- Firebase Setup: Firebase project configured with google-services.json
- Required Host Dependencies: Firebase Messaging, Play Services Location/Maps, WorkManager
- Critical Permission: FOREGROUND_SERVICE_LOCATION (Android 14+)
- Standard Permissions: ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION, POST_NOTIFICATIONS
bubbl-sdk into an Android app without any extra Gradle plugin.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")
}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
- Download
bubbl-sdk-2.0.3.aar (2.1 currently)here. - Copy it into
app/libs/. - 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 withimplementation(name: "bubbl-sdk-2.0.3", ext: "aar")
2.3 Dependency Management & Host Requirements
Required Host Dependencies
The following dependencies must be included in your host application. The SDK usescompileOnly for these to avoid version conflicts:
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+
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.
IllegalStateException: FirebaseApp is not initializedIncompatibleClassChangeErrorwith Google Play ServicesNoSuchMethodErrororClassNotFoundException
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
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>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
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(): StringWhat 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
}
}
}
}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
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
- 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
- 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:
- Never name your resources starting with
bubbl_ - Use your own app's resource naming conventions
- Test thoroughly on different device configurations
- Monitor for any resource-related build warnings
6. Troubleshooting
Critical Runtime Crashes
- 🚨 App crashes on Android 14+ with SecurityException
Error:Foreground service type location not in manifest
Root Cause: MissingFOREGROUND_SERVICE_LOCATIONpermission 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 fromBubblSdk.geofenceFlowafter callingBubblSdk.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 yourgoogle-services.jsonfile is properly placed. - FCM token null?
Verify that Firebase is correctly set up in your project. Double-check your Firebase console and thatgoogle-services.jsonis 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 withbubbl_. 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 usescompileOnlyfor 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(): BooleanCheck 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(): IntGet 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. Rich Notification Modal System
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 indatapayload, notnotificationpayload
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. EnsuresupportFragmentManager.isStateSavedis false
2. Modal display is deferred untilonResume()lifecycle
3. Check that notification tap passes correct intent extras
4. Verify notification permissions are granted - Campaign Issues?
1. CallBubblSdk.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. Checkfile_paths.xmlconfiguration
3. Verify external storage permissions if needed
4. Test log sharing functionality after generating logs