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.9")
// 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.9"
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.9"
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.9")
// ... other dependencies
}2.2 Manual .aar drop-in
- Download
bubbl-sdk-2.0.9.aarhere. - Copy it into
app/libs/. - Add to
build.gradle.kts(Kotlin DSL):repositories { flatDir { dirs("libs") } } dependencies { implementation(files("libs/bubbl-sdk-2.0.9.aar")) }Using Groovy DSL? Replace the dependency line withimplementation(name: "bubbl-sdk-2.0.9", 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.9")
}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"/>
<!-- Optional: For survey haptic feedback on question selection -->
<uses-permission android:name="android.permission.VIBRATE"/>
<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>,
questions: List<SurveyQuestion>? = null,
postMessage: String? = null
)What it models: Rich push content delivered via FCM + Bubbl (media URLs, titles, action metadata). Includes optional survey questions and completion message for survey notifications.
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.9")
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.9")
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.9"
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.9.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. Survey Integration
12.1 Survey System Overview
Surveys in Bubbl are delivered as part of push notification payloads and can include multiple questions of different types. The SDK handles survey parsing, validation, and submission to the backend, while your host app is responsible for rendering the UI and collecting user responses.
Supported Question Types
- BOOLEAN: Yes/No questions
- SINGLE_CHOICE: Radio button selection from options
- MULTIPLE_CHOICE: Checkbox selection (multiple options)
- RATING: 1-5 star rating
- SLIDER: Range slider with labeled options
- OPEN_ENDED: Free text input
- NUMBER: Numeric input
12.2 Survey Data Models
SurveyQuestion
Represents a single survey question received in the notification payload:
data class SurveyQuestion(
val id: Int,
val question: String,
val question_type: QuestionType?,
val has_choices: Boolean,
val position: Int,
val choices: List<SurveyChoice>?
) : Parcelable
data class SurveyChoice(
val id: Int,
val choice: String,
val position: Int
) : ParcelableSurveyAnswer
Represents a user's answer to a survey question:
data class SurveyAnswer(
val question_id: Int,
val type: String,
val value: String,
val choice: List<ChoiceSelection>? = null
)
data class ChoiceSelection(
val choice_id: Int
)QuestionType Enum
enum class QuestionType {
BOOLEAN, // Yes/No questions
NUMBER, // Numeric input
OPEN_ENDED, // Free text input
RATING, // 1-5 star rating
SLIDER, // Range slider with choices
SINGLE_CHOICE, // Radio buttons
MULTIPLE_CHOICE // Checkboxes
}12.3 Survey API Reference
submitSurveyResponse(
notificationId: String,
locationId: String,
answers: List<SurveyAnswer>,
callback: ((Boolean) -> Unit)? = null
)What it does: Submits completed survey answers to the backend. The SDK automatically validates answers before submission.
When to call: After user completes and submits a survey.
Validation: SDK validates answer types match question types and required fields are present.
Validation Rules:
- MULTIPLE_CHOICE/SINGLE_CHOICE/SLIDER: Must have choice selections
- NUMBER: Must be a valid integer
- RATING: Must be integer between 1-5
- BOOLEAN: Must be "true", "false", "yes", or "no"
- OPEN_ENDED: Any string value accepted
Example Usage:
// Example: Submit survey responses
val answers = listOf(
SurveyAnswer(
question_id = 1,
type = "RATING",
value = "5"
),
SurveyAnswer(
question_id = 2,
type = "SINGLE_CHOICE",
value = "Very Satisfied",
choice = listOf(ChoiceSelection(choice_id = 42))
),
SurveyAnswer(
question_id = 3,
type = "OPEN_ENDED",
value = "Great experience!"
)
)
BubblSdk.submitSurveyResponse(
notificationId = "123",
locationId = "456",
answers = answers
) { success ->
if (success) {
Toast.makeText(context, "Survey submitted!", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Submission failed", Toast.LENGTH_SHORT).show()
}
}trackSurveyEvent(
notificationId: String,
locationId: String,
activity: String,
callback: ((Boolean) -> Unit)? = null
)What it does: Tracks survey-related events for analytics (not answer submission).
When to call: Track user interactions with surveys such as delivery, engagement, and dismissal.
Supported Activity Types:
"notification_delivered"- Survey notification was shown"cta_engagement"- User engaged with survey (opened/started)"dismissed"- User dismissed the survey without completing
Example Usage:
// Track when survey modal is shown
BubblSdk.trackSurveyEvent(
notificationId = "123",
locationId = "456",
activity = "notification_delivered"
) { success ->
Log.d("Survey", "Delivery tracked: $success")
}
// Track when user starts answering
BubblSdk.trackSurveyEvent(
notificationId = "123",
locationId = "456",
activity = "cta_engagement"
) { success ->
Log.d("Survey", "Engagement tracked: $success")
}
// Track if user dismisses without completing
BubblSdk.trackSurveyEvent(
notificationId = "123",
locationId = "456",
activity = "dismissed"
) { success ->
Log.d("Survey", "Dismissal tracked: $success")
}12.4 Receiving Surveys via FCM
Surveys are delivered through Firebase Cloud Messaging as part of the notification payload. The SDK's MyFirebaseMessagingService automatically parses survey questions from the FCM message.
FCM Payload Structure
{
"data": {
"id": "42",
"headline": "How was your visit?",
"body": "We'd love your feedback!",
"mediaType": "survey",
"locationId": "123",
"postMessage": "Thank you for your feedback!",
"questions": "[{
\"id\": 1,
\"question\": \"How would you rate your experience?\",
\"question_type\": \"RATING\",
\"has_choices\": false,
\"position\": 1,
\"choices\": null
}, {
\"id\": 2,
\"question\": \"Would you recommend us?\",
\"question_type\": \"BOOLEAN\",
\"has_choices\": false,
\"position\": 2,
\"choices\": null
}, {
\"id\": 3,
\"question\": \"How likely are you to return?\",
\"question_type\": \"SINGLE_CHOICE\",
\"has_choices\": true,
\"position\": 3,
\"choices\": [{
\"id\": 10,
\"choice\": \"Very Likely\",
\"position\": 1
}, {
\"id\": 11,
\"choice\": \"Somewhat Likely\",
\"position\": 2
}, {
\"id\": 12,
\"choice\": \"Unlikely\",
\"position\": 3
}]
}]"
}
}FCM Service Processing
The SDK automatically:
- Parses the "questions" JSON array from FCM data payload
- Filters out questions with null question_type
- Creates SurveyQuestion objects
- Routes to NotificationRouter for display
// Inside MyFirebaseMessagingService.onMessageReceived()
val questionsJson = data["questions"]
if (!questionsJson.isNullOrBlank()) {
try {
val questions = Gson().fromJson(
questionsJson,
Array<SurveyQuestion>::class.java
).toList().filter { it.question_type != null }
Log.d("FCM", "Parsed ${questions.size} valid survey questions")
// Create notification DTO with questions
val dto = PushNotificationDto(
id = data["id"]?.toIntOrNull() ?: 0,
headline = data["headline"] ?: "",
body = data["body"] ?: "",
questions = questions,
postMessage = data["postMessage"],
locationId = data["locationId"] ?: "-1"
)
// Route notification for display
NotificationRouter.push(applicationContext, dto, dto.locationId)
} catch (e: Exception) {
Log.e("FCM", "Failed to parse survey questions: ${e.message}")
}
}12.5 Host App UI Implementation
Complete Survey UI Example
This example shows how to create a ModalFragment that renders all 7 question types dynamically, collects answers, validates them, and submits to the SDK.
class ModalFragment : DialogFragment() {
private var submitButton: Button? = null
private var isSubmitting = false
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val notification = getNotificationFromArgs()
val view = layoutInflater.inflate(R.layout.dialog_notification, null)
setupView(view, notification)
// Track notification delivered
BubblSdk.trackSurveyEvent(
notificationId = notification.id.toString(),
locationId = notification.locationId,
activity = "notification_delivered"
)
return MaterialAlertDialogBuilder(requireContext())
.setView(view)
.setPositiveButton("Close") { _, _ ->
trackDismissal(notification)
}
.create()
}
private fun setupView(view: View, notification: DomainNotification) {
view.findViewById<TextView>(R.id.tv_headline).text = notification.headline
view.findViewById<TextView>(R.id.tv_body).text = notification.body
val surveyContainer = view.findViewById<LinearLayout>(R.id.survey_container)
surveyContainer.visibility = View.VISIBLE
setupSurveyView(view, notification)
}
private fun setupSurveyView(view: View, notification: DomainNotification) {
val questionsContainer = view.findViewById<LinearLayout>(R.id.survey_questions_container)
val submitBtn = view.findViewById<Button>(R.id.btn_survey_submit)
submitButton = submitBtn
val questionViews = mutableMapOf<Int, View>()
// Render each question
notification.questions?.sortedBy { it.position }?.forEach { question ->
val questionView = createQuestionView(question)
questionView?.let {
questionsContainer.addView(it)
questionViews[question.id] = it
}
}
// Track engagement when survey is opened
BubblSdk.trackSurveyEvent(
notificationId = notification.id.toString(),
locationId = notification.locationId,
activity = "cta_engagement"
)
// Handle submission
submitBtn.setOnClickListener {
if (isSubmitting) return@setOnClickListener
val answers = collectAllAnswers(notification.questions, questionViews)
if (answers.size < (notification.questions?.size ?: 0)) {
showError("Please answer all questions")
return@setOnClickListener
}
isSubmitting = true
submitBtn.text = "Submitting..."
BubblSdk.submitSurveyResponse(
notificationId = notification.id.toString(),
locationId = notification.locationId,
answers = answers
) { success ->
if (success) {
Toast.makeText(
context,
notification.postMessage ?: "Thank you!",
Toast.LENGTH_LONG
).show()
dismiss()
} else {
Toast.makeText(context, "Failed to submit", Toast.LENGTH_SHORT).show()
isSubmitting = false
submitBtn.text = "Submit Survey"
}
}
}
}
private fun createQuestionView(question: SurveyQuestion): View? {
if (question.question_type == null) return null
val layoutId = when (question.question_type) {
QuestionType.BOOLEAN -> R.layout.survey_question_boolean
QuestionType.NUMBER -> R.layout.survey_question_number
QuestionType.OPEN_ENDED -> R.layout.survey_question_open_ended
QuestionType.RATING -> R.layout.survey_question_rating
QuestionType.SLIDER -> R.layout.survey_question_slider
QuestionType.SINGLE_CHOICE -> R.layout.survey_question_single_choice
QuestionType.MULTIPLE_CHOICE -> R.layout.survey_question_multiple_choice
}
val view = layoutInflater.inflate(layoutId, null)
view.findViewById<TextView>(R.id.tv_question_number)?.text = "Q${question.position}."
view.findViewById<TextView>(R.id.tv_question_text)?.text = question.question
// Setup specific input type
when (question.question_type) {
QuestionType.RATING -> setupRatingInput(view)
QuestionType.SLIDER -> setupSliderInput(view, question)
QuestionType.SINGLE_CHOICE -> setupSingleChoiceInput(view, question)
QuestionType.MULTIPLE_CHOICE -> setupMultipleChoiceInput(view, question)
else -> {} // Text inputs are ready from layout
}
return view
}
}12.6 Question Type Rendering Patterns
Layout Files Structure
Create separate layout files for each question type:
res/layout/
├── survey_question_boolean.xml
├── survey_question_number.xml
├── survey_question_open_ended.xml
├── survey_question_rating.xml
├── survey_question_slider.xml
├── survey_question_single_choice.xml
└── survey_question_multiple_choice.xmlExample: Boolean Question Layout
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:background="@drawable/survey_card_background">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_question_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Q1."
android:textStyle="bold"
android:textSize="16sp"
android:paddingEnd="8dp" />
<TextView
android:id="@+id/tv_question_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Would you recommend us?"
android:textSize="16sp" />
</LinearLayout>
<RadioGroup
android:id="@+id/radio_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp">
<RadioButton
android:id="@+id/rb_yes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Yes"
android:tag="yes"
android:padding="12dp" />
<RadioButton
android:id="@+id/rb_no"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="No"
android:tag="no"
android:padding="12dp" />
</RadioGroup>
</LinearLayout>Example: Rating Question Layout
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:background="@drawable/survey_card_background">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_question_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Q1."
android:textStyle="bold"
android:textSize="16sp"
android:paddingEnd="8dp" />
<TextView
android:id="@+id/tv_question_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Rate your experience"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/rating_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:gravity="center"
android:orientation="horizontal">
<ImageView
android:id="@+id/star_1"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="4dp"
android:src="@android:drawable/star_big_off"
android:clickable="true" />
<ImageView
android:id="@+id/star_2"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="4dp"
android:src="@android:drawable/star_big_off"
android:clickable="true" />
<ImageView
android:id="@+id/star_3"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="4dp"
android:src="@android:drawable/star_big_off"
android:clickable="true" />
<ImageView
android:id="@+id/star_4"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="4dp"
android:src="@android:drawable/star_big_off"
android:clickable="true" />
<ImageView
android:id="@+id/star_5"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_margin="4dp"
android:src="@android:drawable/star_big_off"
android:clickable="true" />
</LinearLayout>
</LinearLayout>Example: Multiple Choice Layout
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:background="@drawable/survey_card_background">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_question_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Q1."
android:textStyle="bold"
android:textSize="16sp"
android:paddingEnd="8dp" />
<TextView
android:id="@+id/tv_question_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Select all that apply"
android:textSize="16sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/choices_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="vertical">
<!-- CheckBoxes added dynamically -->
</LinearLayout>
</LinearLayout>12.7 Answer Collection & Validation
Collecting Answers from UI
private fun collectAnswer(
question: SurveyQuestion,
questionView: View?
): SurveyAnswer? {
if (questionView == null || question.question_type == null) return null
return when (question.question_type) {
QuestionType.BOOLEAN -> {
val radioGroup = questionView.findViewById<RadioGroup>(R.id.radio_group)
val checkedId = radioGroup?.checkedRadioButtonId ?: -1
if (checkedId == -1) return null
val selectedButton = radioGroup?.findViewById<RadioButton>(checkedId)
val value = if (selectedButton?.tag == "yes") "true" else "false"
SurveyAnswer(
question_id = question.id,
type = "BOOLEAN",
value = value
)
}
QuestionType.RATING -> {
val rating = questionView.getTag(R.id.rating_container) as? Int ?: 0
if (rating == 0) return null
SurveyAnswer(
question_id = question.id,
type = "RATING",
value = rating.toString()
)
}
QuestionType.SINGLE_CHOICE -> {
val radioGroup = questionView.findViewById<RadioGroup>(R.id.radio_group)
val checkedId = radioGroup?.checkedRadioButtonId ?: -1
if (checkedId == -1) return null
val selectedButton = radioGroup?.findViewById<RadioButton>(checkedId)
val choiceId = selectedButton?.tag as? Int ?: return null
SurveyAnswer(
question_id = question.id,
type = "SINGLE_CHOICE",
value = selectedButton.text.toString(),
choice = listOf(ChoiceSelection(choice_id = choiceId))
)
}
QuestionType.MULTIPLE_CHOICE -> {
val container = questionView.findViewById<LinearLayout>(R.id.choices_container)
val selectedChoices = mutableListOf<ChoiceSelection>()
for (i in 0 until (container?.childCount ?: 0)) {
val checkBox = container.getChildAt(i) as? CheckBox
if (checkBox?.isChecked == true) {
val choiceId = checkBox.tag as? Int
if (choiceId != null) {
selectedChoices.add(ChoiceSelection(choice_id = choiceId))
}
}
}
if (selectedChoices.isEmpty()) return null
SurveyAnswer(
question_id = question.id,
type = "MULTIPLE_CHOICE",
value = "YES",
choice = selectedChoices
)
}
QuestionType.SLIDER -> {
val seekBar = questionView.findViewById<SeekBar>(R.id.seekbar)
val progress = seekBar?.progress ?: 0
val choiceId = question.choices?.sortedBy { it.position }?.getOrNull(progress)?.id
if (choiceId == null) return null
SurveyAnswer(
question_id = question.id,
type = "SLIDER",
value = progress.toString(),
choice = listOf(ChoiceSelection(choice_id = choiceId))
)
}
QuestionType.NUMBER -> {
val editText = questionView.findViewById<EditText>(R.id.et_number)
val value = editText?.text?.toString() ?: ""
if (value.isBlank()) return null
SurveyAnswer(
question_id = question.id,
type = "NUMBER",
value = value
)
}
QuestionType.OPEN_ENDED -> {
val editText = questionView.findViewById<EditText>(R.id.et_answer)
val value = editText?.text?.toString() ?: ""
if (value.isBlank()) return null
SurveyAnswer(
question_id = question.id,
type = "OPEN_ENDED",
value = value
)
}
}
}Client-Side Validation
private fun validateAnswers(answers: List<SurveyAnswer>): List<String> {
val errors = mutableListOf<String>()
answers.forEach { answer ->
val questionType = try {
QuestionType.valueOf(answer.type)
} catch (e: IllegalArgumentException) {
errors.add("Unknown question type: ${answer.type}")
return@forEach
}
when (questionType) {
QuestionType.MULTIPLE_CHOICE,
QuestionType.SINGLE_CHOICE,
QuestionType.SLIDER -> {
if (answer.choice == null || answer.choice.isEmpty()) {
errors.add("Please select an option for question ${answer.question_id}")
}
}
QuestionType.NUMBER -> {
if (answer.value.toIntOrNull() == null) {
errors.add("Please enter a valid number for question ${answer.question_id}")
}
}
QuestionType.RATING -> {
val rating = answer.value.toIntOrNull()
if (rating == null || rating < 1 || rating > 5) {
errors.add("Rating must be between 1 and 5 for question ${answer.question_id}")
}
}
QuestionType.BOOLEAN -> {
val value = answer.value.lowercase()
if (value !in listOf("true", "false", "yes", "no")) {
errors.add("Invalid yes/no answer for question ${answer.question_id}")
}
}
QuestionType.OPEN_ENDED -> {
if (answer.value.trim().isEmpty()) {
errors.add("Please provide an answer for question ${answer.question_id}")
}
}
}
}
return errors
}12.8 Survey Styling & UX Best Practices
Recommended Design System
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Survey Colors -->
<color name="survey_primary">#1976D2</color>
<color name="survey_background">#FFFFFF</color>
<color name="survey_card_background">#F5F5F5</color>
<color name="survey_text_primary">#212121</color>
<color name="survey_text_secondary">#757575</color>
<color name="survey_rating_filled">#FFB300</color>
<color name="survey_rating_unfilled">#E0E0E0</color>
<!-- Survey Dimensions -->
<dimen name="survey_padding">16dp</dimen>
<dimen name="survey_card_margin">12dp</dimen>
<dimen name="survey_choice_padding">12dp</dimen>
<dimen name="survey_choice_margin">8dp</dimen>
<!-- Survey Text Sizes -->
<dimen name="survey_question_text_size">16sp</dimen>
<dimen name="survey_choice_text_size">15sp</dimen>
</resources>UX Best Practices
- Staggered Animation: Fade in questions with 100ms delay between each
- Haptic Feedback: Provide tactile feedback on selection (50-100ms vibration). Note: Requires
VIBRATEpermission in AndroidManifest.xml - Progressive Enablement: Enable submit button only when all questions answered
- Loading States: Show "Submitting..." with reduced opacity during submission
- Success Feedback: Animate button on success before auto-dismissing
- Post-Survey Message: Display custom thank you message from notification payload. Always provide a fallback as the postMessage can be lost in transit (e.g.,
notification.postMessage ?: "Thank you for your feedback!") - Error Handling: Show clear validation errors inline with questions
12.9 Complete End-to-End Example
This comprehensive example shows a complete survey integration in a host app, from receiving FCM notifications to submitting responses.
// Complete end-to-end survey integration example
class MainActivity : ComponentActivity() {
private val surveyReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val notificationJson = intent.getStringExtra("notification_json") ?: return
val notification = Gson().fromJson(
notificationJson,
NotificationRouter.DomainNotification::class.java
)
// Check if this is a survey notification
if (notification.mediaType?.lowercase() == "survey" &&
!notification.questions.isNullOrEmpty()) {
showSurveyModal(notification)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Register broadcast receiver for survey notifications
val filter = IntentFilter("tech.bubbl.sdk.NOTIFICATION_RECEIVED")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(surveyReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(surveyReceiver, filter)
}
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(surveyReceiver)
}
private fun showSurveyModal(notification: NotificationRouter.DomainNotification) {
if (supportFragmentManager.isStateSaved) {
// Defer until safe to show
return
}
val modal = ModalFragment.newInstance(notification)
modal.show(supportFragmentManager, "survey_modal")
}
}12.10 Survey Analytics & Tracking
Track Survey Lifecycle Events
// Complete survey analytics tracking example
class SurveyTracker {
// Track when survey notification is delivered to user
fun trackSurveyDelivered(notificationId: String, locationId: String) {
BubblSdk.trackSurveyEvent(
notificationId = notificationId,
locationId = locationId,
activity = "notification_delivered"
) { success ->
Log.d("SurveyAnalytics", "Survey delivered tracked: $success")
}
}
// Track when user opens/engages with survey
fun trackSurveyEngagement(notificationId: String, locationId: String) {
BubblSdk.trackSurveyEvent(
notificationId = notificationId,
locationId = locationId,
activity = "cta_engagement"
) { success ->
Log.d("SurveyAnalytics", "Survey engagement tracked: $success")
}
}
// Track when user dismisses survey without completing
fun trackSurveyDismissed(notificationId: String, locationId: String) {
BubblSdk.trackSurveyEvent(
notificationId = notificationId,
locationId = locationId,
activity = "dismissed"
) { success ->
Log.d("SurveyAnalytics", "Survey dismissal tracked: $success")
}
}
// Submit completed survey (includes implicit completion tracking)
fun submitSurvey(
notificationId: String,
locationId: String,
answers: List<SurveyAnswer>,
onComplete: (Boolean) -> Unit
) {
BubblSdk.submitSurveyResponse(
notificationId = notificationId,
locationId = locationId,
answers = answers
) { success ->
Log.d("SurveyAnalytics", "Survey submission: $success")
onComplete(success)
}
}
}Best Practices for Survey Tracking
- Track
notification_deliveredwhen survey modal is shown - Track
cta_engagementwhen user starts answering questions - Track
dismissedif user closes without completing - Use
submitSurveyResponse()only for actual answer submission - Include locationId for geolocation context
- Handle callback failures gracefully (network issues, etc.)
12.11 Survey Troubleshooting
- Survey Questions Not Appearing?
1. Verify "questions" field is present in FCM data payload
2. Check that questions JSON is properly formatted
3. Ensure question_type is not null (SDK filters null types)
4. Check FCM service logs for parsing errors - Submission Failing with Validation Error?
1. Verify answer types match question types
2. Check RATING answers are integers 1-5
3. Ensure BOOLEAN answers use valid values ("true"/"false"/"yes"/"no")
4. Verify choice-based questions include choice selections
5. Check NUMBER answers are valid integers - Submission Succeeds But Callback Returns False?
1. Check network connectivity
2. Verify API key and environment configuration
3. Ensure device is registered (automatic on SDK init)
4. Check backend logs for server-side errors - Survey UI Rendering Issues?
1. Verify all 7 question type layouts exist
2. Check view IDs match between layout and code
3. Test each question type individually
4. Ensure proper null-safety for optional fields - Post-Survey Message Not Showing?
1. Check notification payload includes "postMessage" field
2. Note: The postMessage field can sometimes be lost in transit - always provide a default fallback message like "Thank you for your feedback!"
3. Display after successful submission callback
4. Use Toast or custom UI to show message
Example:notification.postMessage?.ifBlank { null } ?: "Thank you for your feedback!"
12.12 Reference Implementation
Key files in reference implementation:
ModalFragment.kt- Complete survey UI implementation (1050+ lines)survey_question_*.xml- Layout files for each question typestyles_survey.xml- Survey-specific stylingMainActivity.kt- Broadcast receiver and modal handling
13. 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