Skip to main content

Android SDK Integration

Native Android SDK for tracking events, managing user sessions, sending push notifications via FCM, and displaying in-app messages.

Features

  • Lightweight - Minimal footprint
  • Fast - Optimized for performance
  • Offline Support - SQLite-based event queue
  • Auto-Retry - Exponential backoff on failures
  • Batching - Efficient event batching (50 events / 5s)
  • In-App Messaging - native and HTML messages, 5 types, with frequency capping
  • Push Notifications - Firebase Cloud Messaging (FCM)
  • Privacy-First - GDPR compliant, respects user consent
  • Android 6.0+ - Support for API 23+

Requirements

  • Android 6.0 (API level 23) or higher - the floor for encrypted-at-rest storage (EncryptedSharedPreferences), and the same minimum Firebase Cloud Messaging requires
  • Kotlin 1.9.20 or higher
  • Gradle 8.0 or higher

Local SDK Setup

For local builds, set the Android SDK path in local.properties:

sdk.dir=/Users/your-user/Library/Android/sdk

Alternatively, export ANDROID_HOME/ANDROID_SDK_ROOT before running Gradle.

Installation

Add to your build.gradle.kts:

The SDK ships as two artifacts. Add one of them - the UI artifact contains the base one, so you never declare both:

dependencies {
// Everything, including in-app message display. Start here.
implementation("io.joryio:joryio-android-ui:1.0.0")
}
dependencies {
// Tracking, identity and push only - no in-app rendering, and no WebView
// linked. Choose this if your app does not use in-app messages, or if you
// render them yourself.
implementation("io.joryio:joryio-android:1.0.0")
}

Or using Groovy (build.gradle):

dependencies {
implementation 'io.joryio:joryio-android-ui:1.0.0'
}
Which one?

joryio-android-ui depends on joryio-android, so one line gets you both and they can never fall out of step - you only ever name one version.

Take the base artifact alone when your app has no use for in-app messages, or when a security review objects to a WebView being linkable at all. In-app messages will simply not display; everything else works unchanged.

Quick Start

1. Initialize the SDK

In your Application class:

import io.joryio.sdk.Joryio
import io.joryio.sdk.JoryioConfig

class MyApplication : Application() {
override fun onCreate() {
super.onCreate()

// Initialize with your SDK key
Joryio.initialize(
context = this,
sdkKey = "jry_sdk_android_YOUR_SDK_KEY",
apiHost = "api-eu1.joryio.com"
)
}
}
Finding Your SDK Key

Find your SDK key in the Joryio dashboard under Settings → Apps → [Your App] → SDK Keys.

2. Track Events

// Basic event
Joryio.track("Button Tapped")

// Event with properties
Joryio.track("Product Viewed", mapOf(
"product_id" to "abc123",
"product_name" to "Wireless Headphones",
"price" to 99.99,
"category" to "Electronics"
))

// Screen view
Joryio.trackScreen("ProductDetail", mapOf(
"product_id" to "abc123"
))

3. Identify Users

// Identify a user
Joryio.identify("user-123")
Joryio.getInstance().setAttributes(mapOf(
"email" to "user@example.com",
"name" to "John Doe",
"plan" to "premium"
))

// Set attributes later
Joryio.setAttribute("last_purchase", Date())
Joryio.incrementAttribute("lifetime_value", 99.99)

// On logout
Joryio.reset()

Automatic Session Data

The Android SDK enriches Session Start events with device and environment data:

  • $device_id
  • $platform (android)
  • $manufacturer / $model
  • $os_name / $os_version / $os_sdk_int
  • $app_version / $build_number
  • $package_name
  • $screen_width / $screen_height
  • $locale
  • $language / $languages
  • $timezone
  • country (ISO-3166-1 alpha-2, derived from IP at session start)

Push Notifications

Enable push notifications with Firebase Cloud Messaging (FCM).

1. Add Firebase to Your Project

Follow the Firebase setup guide to add Firebase to your Android project.

2. Add Service to AndroidManifest.xml

<service
android:name="io.joryio.sdk.push.JoryioFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>

3. Register for Push Notifications

import com.google.firebase.messaging.FirebaseMessaging

// Get FCM token and register
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) {
val token = task.result
Joryio.getInstance().registerPushToken(token)
}
}

// Check if push is enabled
val isEnabled = Joryio.getInstance().isPushEnabled()

// Unregister when needed
Joryio.getInstance().unregisterPush()

4. Configure FCM in Dashboard

To send push notifications, configure your Firebase credentials:

  1. Go to Settings → Apps → [Your App]
  2. Open the push notifications configuration
  3. Upload your Firebase service account JSON (from the Firebase console: Project settings → Service accounts → Generate new private key)
  4. Save configuration

In-App Messaging

The SDK displays in-app messages for you. Once you have initialized it, eligible campaigns appear on their own and impressions, clicks and dismissals are tracked automatically - there is nothing to wire up.

Delivery tokens

When the backend serves an eligible campaign it also issues a short-lived signed delivery token for it. The SDK echoes that token back when it reports an impression, click or dismissal, and the server verifies the signature before recording anything.

You do not have to do anything - the SDK handles this for you. It is documented because it changes what happens to a client that does not send one:

POST /v1/in-app/track   (no deliveryToken)
{ "success": false, "error": "A delivery token is required" }

The token is what makes an impression trustworthy: without it, anyone holding the SDK key - which ships inside every app and page - could report impressions and clicks for a campaign that was never shown, and your reporting would count them.

If in-app impressions stop being recorded, check that the app is built against a current version of the SDK: a build made before delivery tokens existed sends no token, and the server will reject its impressions.

Two kinds of message content

A campaign arrives as one of two content shapes, and the SDK renders each one differently:

ContentWhat it isHow it renders
NativeStructured data - headline, body, image, buttonsReal Android views, using your app's theme, fonts, dark mode and TalkBack. No WebView.
HTMLAuthor-supplied markup, CSS and JavaScriptA WebView inside the message.

Allowing HTML messages

HTML messages are turned off by default. An HTML message runs author-supplied JavaScript inside your app, so turning it on is a decision your app team makes - not something switched on from a marketing dashboard:

val config = JoryioConfig(
allowHtmlJsInAppMessages = true // default: false
)

Joryio.initialize(
context = this,
sdkKey = "jry_sdk_android_YOUR_KEY",
apiHost = "api-eu1.joryio.com",
config = config
)

Leaving it off does not disable in-app messaging. Native messages still display, because they are data your app renders with its own views - no interpreter is involved. HTML campaigns are skipped and logged, so an app that has not opted in shows no message rather than a broken one.

If your security policy forbids running authored HTML in-process, leave this off and author your campaigns as native messages.

Taking over rendering yourself

Set a callback to draw your own UI instead. This overrides the SDK's rendering, so you will not get two copies of the message:

Joryio.getInstance().setInAppMessageCallback { campaign ->
showInAppMessage(campaign) // your UI
}

// Report what happened - the SDK only tracks automatically for messages it displays itself
Joryio.getInstance().trackInAppImpression(campaignId, "viewed")
Joryio.getInstance().trackInAppImpression(campaignId, "clicked")
Joryio.getInstance().trackInAppImpression(campaignId, "dismissed")

Rendering messages yourself

The SDK draws native messages for you, but you can take over completely - the custom view factory equivalent:

Joryio.getInstance().setInAppMessageCallback { campaign ->
// draw it however you like
}

Setting a callback overrides the built-in renderer rather than running alongside it, so the message appears once, not twice.

campaign.content is already resolved - Liquid is rendered server-side, and native fields arrive as plain text (never pass them to a WebView; they are not HTML-escaped, precisely because they are meant for text views). Report what you display with trackInAppImpression(campaignId, "impression" | "clicked" | "dismissed") so analytics still line up.

Message Types

The SDK supports 5 message types:

  1. Modal - Center of screen with backdrop
  2. Banner - Top of screen
  3. Slide-Up - Small notification from the bottom
  4. Full-Screen - Takeover message
  5. Custom - Your app decides placement

Attribute delivery

setAttributes is durable. A write is queued until the server acknowledges it, so an attribute set while the device is offline is delivered when connectivity returns rather than dropped.

  • Retried on the next setAttributes, on foreground, and before an in-app sync.
  • Batched - a burst of calls becomes one request (800ms window). A single write still goes out promptly.
  • Persisted in the platform's encrypted store (iOS Keychain, Android EncryptedSharedPreferences), so a write survives the process being killed. Cleared the moment the server acks, and purged by optOut() and wipeData().

Attributes are also used for in-app targeting. The server profile is authoritative: only writes the server has not yet acknowledged can override it, which is what keeps two devices belonging to the same contact from disagreeing about who that contact is.

Configuration Options

Customize SDK behavior with JoryioConfig:

val config = JoryioConfig(
// Initial user ID (optional)
userId = "user-123",

// Event batching
batchSize = 50, // Events per batch
flushInterval = 5000, // Flush interval in ms (5s)

// Session management
sessionTimeout = 1800000, // Session timeout in ms (30 min)
trackSessionStart = true, // Auto-track session start

// Network retry
maxRetries = 3, // Max retry attempts

// Privacy controls
optOut = false, // Opt out of tracking
trackingConsent = TrackingConsent.GRANTED,

// In-app messaging
// Allow HTML in-app messages, which run author-supplied JavaScript in a
// WebView. Native messages always display regardless of this setting.
allowHtmlJsInAppMessages = false,

// Debugging
enableDebug = false, // Enable debug logging
logLevel = LogLevel.ERROR // Log level
)

Joryio.initialize(
context = this,
sdkKey = "jry_sdk_android_YOUR_KEY",
apiHost = "api-eu1.joryio.com",
config = config
)
enum class TrackingConsent {
GRANTED, // Full tracking allowed
PENDING, // Waiting for user decision
DENIED // User denied tracking
}

Log Levels

enum class LogLevel {
VERBOSE, // All logs
DEBUG, // Debug and above
INFO, // Info and above
WARN, // Warnings and errors
ERROR // Errors only
}

Advanced Features

Session Management

Sessions automatically track user engagement:

// Sessions are managed automatically with 30-minute timeout
// Get current session ID
val sessionId = Joryio.getInstance().getSessionId()

// Sessions refresh on user activity

User Attributes

// Set multiple attributes
Joryio.getInstance().setAttributes(mapOf(
"age" to 28,
"city" to "San Francisco",
"premium" to true
))

// Set single attribute
Joryio.setAttribute("language", "en")

// Increment numeric attribute
Joryio.incrementAttribute("page_views", 1)
Joryio.incrementAttribute("total_spent", 29.99)

// Remove attribute
Joryio.getInstance().unsetAttribute("temporary_flag")

Privacy Controls

// Stop collecting. PERSISTED - survives an app restart.
Joryio.getInstance().optOut()

// Opt back in
Joryio.getInstance().optIn()

// Check opt-out status
if Joryio.getInstance().isUserOptedOut() {
print("User has opted out")
}

// Delete everything the SDK stored on this device.
// SEPARATE from optOut(): "stop collecting" and "delete what you have" are
// different requests. This is the one an erasure request needs. It does NOT
// opt the user out - call optOut() as well if that is also intended.
Joryio.getInstance().wipeData()

// Get identity info
val (userId, anonymousId) = Joryio.getInstance().getIdentity()
println("User: ${userId ?: "anonymous"}, Anonymous ID: $anonymousId")

What optOut() does, precisely:

stops collectionyes - track, identify and setAttributes all become no-ops
survives a restartyes - the flag is stored on the device and read before anything is collected
drops what is already queuedyes - queued events and un-acked attribute writes are discarded, not delivered later
tells the serveryes - one final $tracking_opted_out profile attribute, best-effort, sent while sending is still permitted
deletes stored datano - use wipeData()

The $tracking_opted_out attribute is a record, not enforcement: it lands on the profile so campaigns can exclude on it. Server-side suppression is a separate setting.

Manual Queue Flushing

// Flush events immediately
Joryio.flush()

// Useful before app termination
override fun onDestroy() {
super.onDestroy()
Joryio.flush()
}

Best Practices

1. Initialize Early

Initialize in your Application class:

class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
Joryio.initialize(
context = this,
sdkKey = "jry_sdk_android_YOUR_KEY",
apiHost = "api-eu1.joryio.com"
)
}
}

2. Track Screen Views

Use screen tracking for navigation:

override fun onResume() {
super.onResume()
Joryio.trackScreen(this::class.simpleName ?: "Unknown")
}

3. Handle User Logout

Always reset on logout:

fun logout() {
// Clear user session
clearUserSession()

// Reset SDK
Joryio.reset()
}

API Reference

Event Tracking

// Track event
Joryio.track(
eventName: String,
properties: Map<String, Any?> = emptyMap()
)

// Track screen view
Joryio.trackScreen(
screenName: String,
properties: Map<String, Any?> = emptyMap()
)

// Flush events immediately
Joryio.flush()

User Identity

// Identify user
Joryio.identify(
userId: String
)

// Set attributes
Joryio.getInstance().setAttributes(
attributes: UserAttributes
)

// Alias user
Joryio.alias(userId: String)

// Reset user (logout)
Joryio.reset()

// Get IDs
Joryio.getInstance().getAnonymousId(): String
Joryio.getInstance().getUserId(): String?
Joryio.getInstance().getSessionId(): String

In-App Messaging

// Set message callback
Joryio.getInstance().setInAppMessageCallback { campaign ->
// Handle message display
}

// Track impressions
Joryio.getInstance().trackInAppImpression(
campaignId: String,
action: String
)

Push Notifications

// Register token
Joryio.getInstance().registerPushToken(token: String)

// Check status
Joryio.getInstance().isPushEnabled(): Boolean

// Unregister
Joryio.getInstance().unregisterPush()

Troubleshooting

Events Not Appearing

  1. Check SDK key is correct: jry_sdk_android_*
  2. Enable debug logging: enableDebug = true
  3. Check logcat for errors
  4. Verify network permissions in manifest
  5. Call flush() to send immediately

Push Not Working

  1. Verify Firebase is properly configured
  2. Check the Firebase service account JSON is uploaded in the dashboard
  3. Ensure device token is registered
  4. Test with Firebase Console first

Build Errors

  1. Ensure minimum SDK version is 23
  2. Sync Gradle dependencies
  3. Clean and rebuild project

E-Commerce Tracking

The Android SDK includes a built-in e-commerce tracker for product, cart, checkout, and order events. See the cross-SDK E-Commerce Tracking guide for the full API with Kotlin examples.

Testing and diagnostic APIs

Two groups, and the difference matters.

Testing APIs - ignored unless enableDebug is on. They CHANGE live state, so a stray call in a production build would corrupt real frequency caps and reporting. With debug off they log a warning and do nothing.

MethodWhat it does
resetDisplayedCampaigns()Forgets which in-app campaigns were already shown, so they can display again. Frequency state is held on the device - that is what lets a trigger fire instantly and offline - so changing the campaign server-side will NOT let it re-show. This is the only way to re-test without reinstalling.
evaluateInAppCampaigns()Re-runs the display decision over campaigns already synced, without a network call.

Diagnostic APIs - always available, including in production. They only read state, so they cannot damage anything, and a support screen wants them most when something is wrong in the field. Gating them would have forced verbose logging on just to read a session id.

MethodAnswers
getQueueSize()How many events are waiting to be sent?
currentApiEndpoint / lastTransportErrorWhich server are we talking to, and did the last call fail? "Initialized" and "the server accepts us" are different claims.

getIdentity(), getSessionInfo() and getDeviceToken() are NOT in either group - they are ordinary API. Reading who this device reports as is a normal thing for an app to do (forwarding a device token to your own backend, showing a support ID on an account screen), and every major SDK exposes the equivalents unconditionally.

Next Steps