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
  • Java 17 to build with Android Gradle Plugin 8. The SDK's classes are Java 11 bytecode, so a JDK 11 build (Unity 2022.3) compiles against them too
  • 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.3.3")
}
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.3.3")
}

Or using Groovy (build.gradle):

dependencies {
implementation 'io.joryio:joryio-android-ui:1.3.3'
}
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 = "https://api-eu1.joryio.com/api"
)
}
}
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​

Send phone numbers with + and the country code

Always send phone numbers in full international format (E.164): +, the country code, then the number - for example +972501234567. A number without + is read using the workspace's default country for phone numbers; if none is set, or the digits can be read two ways, it is saved as unverified and not used for messages. How phone numbers are handled

// Identify a user
Joryio.identify("user-123")
Joryio.getInstance().setAttributes(mapOf(
"email" to "[email protected]",
"name" to "John Doe",
"plan" to "premium"
))

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

// Attach another identifier of yours (label: lowercase letters, digits, _).
// Works before identify() too - it follows the visitor through login.
Joryio.addAlias("loyalty_id", "L-12345")

// On logout: forgets the user AND starts a new anonymous ID
Joryio.reset()

identify is how an anonymous visitor becomes a known user; there is no separate alias(userId) call. reset() also clears attribute writes not yet sent and any in-app message waiting to show, re-registers the push token under the new anonymous ID and syncs in-app messages again, so the next person on the device is never recorded as the previous one.

Calls on Joryio made before initialization finishes are queued and run in order once it does, with initialize as well as initializeAsync. No public call throws into your code: a missing argument (a null from Java) is logged and ignored. Joryio.getInstance() before initialize() is the one exception - see below.

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).

When to ask

Ask for the permission when the user has a reason to say yes. A prompt in the first seconds of a session is the one most often denied, and a denial is far harder to reverse than a delay - the system prompt is one-shot, so a "no" there is effectively permanent, while a later ask costs you nothing.

The SDK never prompts on your behalf. If you have decided you want it at startup anyway, set requestPushPermissionAtLaunch in the config - it is off by default rather than unavailable.

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) { result ->
if (!result.success && result.isRetryable) {
// transient (offline, DNS, 5xx): register again when the network is back
}
if (result.isUnauthorized) {
// wrong SDK key - retrying will not help
}
}
}
}

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

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

Pass onResult to hear how it went: isRetryable means a transient failure (offline, DNS, 5xx) worth registering again; isUnauthorized means the SDK key is wrong. Without it the call is fire-and-forget. A registration counts only when the server confirms it; an answer of success: false is reported as a failure and retried on the next foreground. After unregisterPush(), calling registerPushToken() registers the device again. Do not pass Firebase's automatic token callbacks (onNewToken() in your own messaging service, the token fetched at every launch) to registerPushToken() while the user has turned push off: it is the call that turns push back on. The SDK's own messaging service handles new tokens itself and never undoes unregisterPush(), and a Joryio.unregisterPush() made before initialize() wins over the token Firebase hands over at launch.

requestPushPermission(activity) works with any Activity. When yours is a plain android.app.Activity (as in Unity), the SDK shows the system dialog from its own invisible activity and calls you back when the user answers. PushPermissionStatus.publicName gives the status under the name every Joryio SDK uses: granted, denied, notDetermined or unavailable.

The SDK's notification channel is called "Notifications" in system settings. Set notificationChannelName in JoryioConfig to show your own, localized name.

4. Configure FCM in Dashboard​

To send push notifications, enter your Firebase credentials on the Android app in Joryio:

  1. Go to Settings → Apps and click the Push Notifications (bell) icon on your Android app's row
  2. Click Upload service account JSON and choose your Firebase service account key (Firebase console: Project settings → Service accounts → Generate New Private Key)
  3. Click Save configuration

For step-by-step instructions on generating the key, see Getting your Firebase (FCM) credentials.

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 the server can be configured to require it. By default a report that carries no token is still accepted - the server logs it as a pre-token client - and only when the backend runs with IN_APP_REQUIRE_DELIVERY_TOKEN=true is that same report rejected:

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

A token that is present but does not verify - forged, tampered with or past its lifetime - is rejected whatever the setting.

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 on a backend that enforces tokens, check that the app is built against a current version of the SDK: a build made before delivery tokens existed sends no token.

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 and CSS, kept as written (only <base> and <meta http-equiv="refresh"> are removed)A WebView inside the message, under a Content-Security-Policy where only the SDK's own bridge script runs.

Allowing HTML messages​

HTML messages are turned off by default. Scripts written into a message do not run: each message gets a fresh Content-Security-Policy nonce, so only the SDK's bridge script runs - an author's <script> blocks, on* handlers and embeds stay inert, and the bridge handles buttons, data-joryio-action attributes and data-jry-form forms. It is still a web view 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 = "https://api-eu1.joryio.com/api",
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, "displayed")
Joryio.getInstance().trackInAppImpression(campaignId, "clicked", "cta") // which button
Joryio.getInstance().trackInAppImpression(campaignId, "dismissed")

The action is one of displayed, clicked, dismissed or converted; anything else is logged and not sent. Only displayed counts toward the campaign's frequency cap - a click or a dismissal does not use up another showing. The third argument, elementId, names the button or link and is sent separately from the action.

Since 1.3.2. If you set the callback after initialize(), the first sync can finish before it and the SDK draws that message itself. To prevent that, pass JoryioConfig(holdInAppMessagesForHandler = true): a message that is ready before your callback is set is held - not drawn, not reported, nothing counted against a frequency cap - and handed to the callback the moment you set it. Calling clearInAppMessageCallback() instead ends the hold and the SDK draws the held message. The hold covers only the time before the first set or clear.

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, "displayed" | "clicked" | "dismissed" | "converted") 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

When a message counts as displayed​

A message counts as displayed - in_app.displayed is reported and the campaign's frequency cap is spent - only once the person could have seen it. The time it must be on screen is IN_APP_DISPLAY_DWELL_MS (1000 ms), the same constant on every Joryio SDK.

  • The SDK shows a message only on the resumed Activity, and only if it is not finishing, destroyed or being recreated (a rotation). Otherwise the message is held for the next screen.
  • The message counts as displayed after it has been on screen for 1 second while the Activity stays resumed. If the Activity is paused first, the second starts again when it resumes.
  • If the screen closes before then - a sign-in redirect, a logout - nothing is reported and the cap is untouched; the message is offered on the next screen.
  • A tap or a close before then counts as seen: displayed is reported first, then the click or dismissal. A reset, a change of user or an opt-out before then reports nothing.
  • Rendering messages yourself: report displayed by the same rule - once the message has been visible for IN_APP_DISPLAY_DWELL_MS, or right before a click or dismissal that comes sooner. A custom InAppMessagePresenter sends InAppMessagePresenter.EVENT_HIDDEN when it removes a message without the person acting on it.

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.

Marketing Subscription​

Whether this person may be messaged on a channel. A different consent from optOut() above, which is whether they may be observed - neither implies the other, and someone can decline tracking and still want the newsletter.

This is what an in-app preference centre writes.

Joryio.setSubscription("email", "unsubscribed")

Channels: email, sms, whatsapp, push, viber. Statuses: optedIn, subscribed, unsubscribed.

Subscription groups are lists a person can join or leave per channel:

Joryio.addToSubscriptionGroup("list_abc123", "email")
Joryio.removeFromSubscriptionGroup("list_abc123", "email")

An identifier is required - the call is skipped, with a logged error, if there is neither a user id nor an anonymous id.

Unsubscribing always works. setSubscription(channel, "unsubscribed") and removeFromSubscriptionGroup are accepted and delivered even while the person is opted out, consent is DENIED or PENDING, and after wipeData() - a request to stop messaging is never held back or discarded. Subscribing (subscribed, optedIn, addToSubscriptionGroup) is refused while opted out or denied, and held while consent is pending. A newer change of the same channel or group replaces an older one still waiting to be sent, and a change made before identify() that is sent after it carries the user id as well.

Configuration Options​

Customize SDK behavior with JoryioConfig:

val config = JoryioConfig(
// Initial user ID (optional)
userId = "user-123",
anonymousId = null, // Your own anonymous id; generated if null

// Event batching
batchSize = 50, // Events per batch (at most 500)
flushInterval = 5000, // Flush interval in ms (5s); never below 1000
sendImmediately = false, // Flush after every track, no batching
maxQueueSize = 1000, // Most events kept on the device; the oldest go first

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

// Network retry
maxRetries = 3, // Immediate retries of one request before backing off
retryBackoffMs = 1000, // Base backoff between retries (ms)
requestTimeout = 10000, // Per-request HTTP timeout (ms)

// SDK authentication (optional, JWT)
enableSdkAuthentication = false,
sdkAuthenticationToken = null,

// Privacy controls
optOut = false, // Start opted out (remembered, like optOut())
trackingConsent = null, // null = not set; see Tracking Consent below

// Push: ask for notification permission at the first foreground
requestPushPermissionAtLaunch = false,

// In-app messaging
// Allow HTML in-app messages, shown in a WebView. Scripts in them do not
// run; the SDK's bridge handles buttons and forms. Native messages always
// display regardless of this setting.
allowHtmlJsInAppMessages = false,
holdInAppMessagesForHandler = false, // since 1.3.2; true: your app draws messages and sets its callback after initialize()

// Push: the channel name users see in system settings (Android 8+)
notificationChannelName = "Notifications",

// Debugging
enableDebug = false, // Enable debug logging
logLevel = null // null (default): follows enableDebug. Set to pin a level.
)

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

Consent and opt-out are two separate switches, the same on every Joryio SDK:

SwitchSet byCalls
Tracking consentyour consent bannerupdateConsent(...), trackingConsent in the config
Opt-outthe person's own choiceoptOut() / optIn(), optOut in the config

The SDK collects only when consent is GRANTED and the person has not opted out - any "no" wins. Consent never lifts an opt-out (only optIn() does), and optOut() never changes the stored consent.

Joryio.updateConsent(TrackingConsent.GRANTED) // everything held is sent, in order
Joryio.updateConsent(TrackingConsent.DENIED) // everything held is discarded
Joryio.updateConsent(TrackingConsent.PENDING) // hold everything on the device
  • PENDING keeps events and attributes on the device (up to maxQueueSize) and sends nothing - no in-app messages, no push registration - until GRANTED releases them in order, or DENIED discards them. It applies to this launch only and never overrides a stored DENIED. A notification tap is held too and reported on GRANTED.
  • DENIED collects nothing and discards what is held. It is remembered on the device: it stays in force across restarts until updateConsent(TrackingConsent.GRANTED) or an explicit trackingConsent = TrackingConsent.GRANTED in the config. It does not opt the person out - isUserOptedOut() stays false.
  • Not set (trackingConsent = null, the default): a stored DENIED stays in force; otherwise the SDK behaves as GRANTED, which is right for an app with no consent banner.

Read the current value with getTrackingConsent().

Log Levels​

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

logLevel defaults to null, which means "follow enableDebug": DEBUG when debugging is on, ERROR when it is off. Set it explicitly only to pin a level - an explicit LogLevel.ERROR with enableDebug = true keeps the SDK silent.

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

Time in the background counts toward the timeout: an app brought back after more than sessionTimeout starts a new session and sends a new Session Start.

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.optOut()

// Opt back in - the only call that lifts an opt-out
Joryio.optIn()

// The opt-out switch only; consent is getTrackingConsent()
if (Joryio.isUserOptedOut()) {
Log.i("MyApp", "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.wipeData()

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

What optOut() does, precisely:

stops collectionyes - every write becomes a no-op: events, identify, attributes (including increments and unsets), aliases, subscribes, push registration, delivery receipts and click tracking, in-app reports. Unsubscribes and a push unregister still go out
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. An unsubscribe not yet delivered is kept and sent
tells the serveryes - the tracking_opted_out profile attribute, sent when the switch changes (also for an opt-out decided at or before initialization) and retried until the server has it, whatever the consent for true (the false of optIn() waits for consent GRANTED)
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 = "https://api-eu1.joryio.com/api"
)
}
}

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()
}

Delivery, consent and data lifecycle​

Writes are kept until the server confirms them. identify, addAlias, incrementAttribute, unsetAttribute, setSubscription and the subscription-group calls are held in an encrypted outbox on the device and sent in the order they were made. A failure - offline, a timeout, a 5xx, a 429, or a 200 that answers success: false - keeps the write and retries it with backoff, after a restart too. Retry-After is honoured, in seconds or as a date, and even flush() waits it out. A write the server will never accept - a 4xx such as 400, 409, 413 or 422, or a 403 from your identity policy - is dropped with a log line, so it cannot block the ones behind it. Offline, a timeout, a 429 or a 5xx never count against a write. A 401 or a 200 with success: false is retried at most ten times for that write, a 404 (no profile yet) at most five: a subscription change answered 404 is retried after the queued events are sent, since those create the profile. Values setAttributes sends get the same ten answers. Attribute calls on the same key are applied in the order you made them, whichever of setAttribute, unsetAttribute and incrementAttribute you used. setAttributes has its own durable queue, described above; attributes the server refuses as invalid (400) are dropped there too.

Event limits. The server refuses an event whose properties are larger than 50 KB, have more than 200 keys or nest deeper than 5 levels, or whose name is empty or longer than 255 characters. track() checks these on the device, measuring size as the server does (a character such as < or & counts once), and drops such an event with a log line, instead of queueing something that can never be delivered. A name made only of spaces is a valid name. An event the server still refuses for good is dropped, not retried.

A null value removes an attribute. setAttribute("plan", null) is the same as unsetAttribute("plan"), on every Joryio SDK.

Sessions. A session ends after sessionTimeout without activity, whether the app was in the background or stayed open on one screen. The first event after that starts a new session and sends its Session Start first. reset() starts a new session too. A launch within sessionTimeout of the last activity continues that session and sends no new Session Start. A session that started while tracking was refused (opted out or consent denied) is not continued that way: the first launch that may track starts a new session with its Session Start.

Consent at launch. optOut = true works like optOut(): it is remembered on the device, so a later launch stays opted out until optIn(). trackingConsent = TrackingConsent.DENIED is remembered too, as consent (see Tracking Consent). Either way, anything an earlier launch left queued is discarded. Joryio.optOut(), Joryio.optIn(), Joryio.updateConsent() and Joryio.wipeData() called before initialize() are applied by initialize() itself, before anything is collected or sent - and are kept on the device until then, so a decision made before the SDK started survives the app being closed. Before initialize(), Joryio.isUserOptedOut() and Joryio.getTrackingConsent() answer what is stored on the device - an opt-out or a denial from an earlier launch - not a default. A Joryio.updateConsent(TrackingConsent.PENDING) before initialize() applies to that launch only and never reopens a denial, and a Joryio.optIn() or Joryio.optOut() before initialize() wins over optOut in the config. With nothing stored, Joryio.getTrackingConsent() answers GRANTED, the same as on iOS.

SDK authentication. With enableSdkAuthentication on, the handler set with setSdkAuthenticationErrorHandler is called for every authentication 401, including when no token has been set yet (reason MISSING): supply one with setSdkAuthenticationToken. Events are sent in batches of one user at a time; events or writes of a user who has since logged out that the server refuses, for any authentication reason, are dropped, so they never hold back the next user's. The handler is called only for requests of the user who is signed in now, never for a logged-out user's leftovers. reset() takes the token out of the session; it stays with the outgoing user's leftovers. Each request carries the token whose sub is that request's user - never one that names someone else - and a request of an anonymous visitor carries none. A token set for the next user before reset(), also while no one is signed in, is kept for them and used again at their identify(); wipeData() deletes it. Otherwise set the next user's token with setSdkAuthenticationToken once they sign in. Events and writes the outgoing user had already queued still go out with that user's token, so a normal logout loses nothing.

Before initialization. Calls on Joryio made before initialize() finishes are buffered - up to 1000, the oldest dropped first - and replayed in order. An event, attribute write, alias or subscribe made through them while the person is opted out or consent is denied - decided before initialize() or stored by an earlier launch - is dropped at once, so a later optIn() or grant never sends it; unsubscribes, identify() and reset() are kept. An optOut() or updateConsent(DENIED) made before initialize() also drops the events, attribute writes, aliases and subscribes queued before it, as it does after initialization, so a later optIn() or grant before initialize() never sends them. A token set with Joryio.setSdkAuthenticationToken() before any identify(), reset() or wipeData() is applied by initialize() itself, before its first request. Set the token and the handler with the static Joryio.setSdkAuthenticationToken() and Joryio.setSdkAuthenticationErrorHandler(), not inside whenReady: only those are taken ahead of the first request and kept through a wipeData() made before initialize(), so the erased user's unsubscribes still go out with their token. Joryio.getInstance() is the one call that throws (IllegalStateException) before initialize(); where initialization may not have finished, use the static methods or Joryio.whenReady { sdk -> ... }.

Paused account (402). A 402 means your account's ingestion is paused. It never counts against a write's retry limit, and it does not hold back withdrawals: an unsubscribe, a group leave and the tracking_opted_out = true record queued behind a refused write are still sent, while the other writes wait and are retried with backoff.

Logout and account switch. The outgoing user's SDK authentication token is kept in the encrypted store together with that user's queued events and writes, so they are delivered with it even when the app is closed before they go out; it is deleted once they are delivered. identify() of a different user without reset() is treated the same way for the previous user: their unsent attributes are sent under their own ids, and the token goes with their leftovers - unless you already set the new user's token, which the SDK recognises by its sub. A queued unsubscribe is only replaced by a newer change of the same user.

identify() while opted out or denied. The user id is kept on the device only - nothing is sent and no profile is written - so that an unsubscribe made meanwhile carries it. Once tracking is allowed again (optIn(), updateConsent(TrackingConsent.GRANTED)), identify() runs as usual. reset() and wipeData() forget it. That also happens at a later launch whose stored or configured state allows tracking. A new identify() replaces the held user id, and a userId in the config wins over it. The opt-out record and a push unregister made meanwhile carry it too, and under SDK Authentication a refusal of those asks your handler for that user's token.

Numbers JSON cannot carry. A NaN or Infinity in an attribute value or in an event or e-commerce property is dropped - that key only - with a log line, and the rest of the call goes ahead. A null line in an e-commerce items list is skipped.

Reading the session changes nothing. getSessionId() and getSessionInfo() never start or extend a session. Only events and the app coming to the foreground do, and a new session always sends its Session Start.

Full buffer before initialization. When the 1000-call buffer is full, the oldest call that is not an unsubscribe, a group leave or Joryio.unregisterPush() is dropped first.

Keys, storage and threads. An SDK key minted for another platform (jry_sdk_ios_..., jry_sdk_web_..., a store) is refused with an error and the SDK sends nothing. A Keystore error is retried and never deletes the encrypted store; only a store that cannot be decrypted (a restore from backup) is recreated, and a launch that ran while the Keystore was unavailable starts on a plain file without the stored identity; at the next launch that opens the encrypted store, its last change to each value wins - a logout, wipeData(), optIn() or a consent grant made then included - while the anonymous id and device id it had to generate do not replace the stored ones, and queued withdrawals and writes from both launches are kept. If that launch logged out or signed in another user, the stored user's anonymous id is not carried over and their unsent attributes are never sent as the new user - dropped after a logout, sent under their own ids otherwise. A master key that stays unusable - on three launches in a row with no store opening under it, and either a cause that says it cannot decrypt or failures that have lasted at least a day - is replaced by a new key of the SDK's own (your app's androidx key is never touched). The encrypted store under the old key is then discarded: whatever it still held that the plain-file launches did not write again, queued withdrawals included, is lost; a store the old key can still open is moved to the new key first. The main thread never waits for a retry. With initializeAsync(), an Activity that opened while the SDK was starting is picked up - for in-app messages, the launch permission prompt and the notification tap that opened it. Your in-app message callback runs on the main thread, and a message type this SDK version does not know arrives as MessageType.CUSTOM.

HTML messages stay in their document. Only the message's own document loads. Any navigation it starts - a link, a refresh, a frame - is cancelled, and a link the user taps opens in the browser. Its CSS cannot close its own <style> element.

unregisterPush() lasts. The token is forgotten on the device at once and the request is kept until the server confirms it. The SDK does not register a token on its own again - not at the next launch, not when Firebase rotates the token - until you call registerPushToken() or the user grants permission through requestPushPermission().

wipeData() erases the anonymous and user id, the event queue, pending writes, in-app state and impression history, the session, the device id and the push token - and unregisters that token on the server, under the identity being erased. It keeps an existing opt-out: deleting data is not consent. Push then stays off until the app calls registerPushToken() or a permission is granted again - a token FCM hands over later is not registered automatically.

One display per trigger. A campaign is claimed the moment it is chosen, before any display delay, so two triggers in quick succession (the same event tracked twice, a sync landing while a delayed message waits) show it once. The claim ends when the message is dismissed (also while nothing may be sent) or could not be shown. Nothing is displayed while consent is not GRANTED or the person opted out. A sync that was already in flight when identify(), reset(), wipeData() or a consent change happened is discarded, and a fresh sync follows it.

In-app frequency. Only a displayed report spends a campaign's frequency cap and the gap before the next campaign. The SDK's own renderer reports it when a message is drawn; with setInAppMessageCallback you report it with trackInAppImpression(campaignId, "displayed"). Handing a message to your callback spends nothing, so a message you decide not to show does not hold back the next one. clearInAppMessageCallback() hands rendering back to the SDK. A presenter installed with setInAppPresenter reports through the onEvent callback it receives ("displayed", "clicked", "dismissed") and must not also call trackInAppImpression, or the message is counted twice.

Opt-out record. optOut() writes the profile attribute tracking_opted_out = true (no $ prefix), and optIn() sets it back to false - each only when the switch actually changes. A record still queued when its user logs out stays that user's and is delivered to their profile, never re-addressed to the next person.

API Reference​

Every public method, with its parameter names. Calls written Joryio.x() are static and safe before initialization; calls on Joryio.getInstance() need an initialized SDK.

// Setup
Joryio.initialize(context: Context, sdkKey: String?, apiHost: String?, config: JoryioConfig? = JoryioConfig()): Joryio
Joryio.initializeAsync(context: Context, sdkKey: String?, apiHost: String?, config: JoryioConfig? = JoryioConfig(), onReady: ((Joryio) -> Unit)? = null)
Joryio.isInitialized(): Boolean
Joryio.getInstance(): Joryio // throws IllegalStateException before initialize()
Joryio.whenReady(action: ((Joryio) -> Unit)?) // runs now, or as soon as the SDK is initialized
Joryio.SDK_VERSION: String

// Events
Joryio.track(eventName: String?, properties: Map<String, Any?>? = emptyMap())
Joryio.trackScreen(screenName: String?, properties: Map<String, Any?>? = emptyMap())
Joryio.flush()
Joryio.getInstance().ecommerce(config: EcommerceConfig? = null): EcommerceTracker

// Identity and attributes
Joryio.identify(userId: String?)
Joryio.addAlias(label: String?, name: String?) // works before identify() too
Joryio.reset() // logout: forgets the user, new anonymous id
Joryio.setAttribute(key: String?, value: Any?) // a null value removes the attribute
Joryio.setAttributes(attributes: Map<String, Any?>?)
Joryio.incrementAttribute(key: String?, by: Number? = 1)
Joryio.unsetAttribute(key: String?)
Joryio.getInstance().getAnonymousId(): String
Joryio.getInstance().getUserId(): String?
Joryio.getInstance().getSessionId(): String
Joryio.getInstance().getSessionInfo(): SessionInfo
Joryio.getInstance().getIdentity(): Pair<String?, String> // (userId, anonymousId)

// Tracking consent, opt-out and stored data
Joryio.optOut()
Joryio.optIn()
Joryio.isUserOptedOut(): Boolean // the opt-out switch only
Joryio.updateConsent(consent: TrackingConsent?) // GRANTED, DENIED or PENDING
Joryio.getTrackingConsent(): TrackingConsent? // before initialize(): the stored decision, or GRANTED when there is none
Joryio.getInstance().getTrackingConsent(): TrackingConsent
Joryio.wipeData() // safe before initialize()

// Marketing subscription
Joryio.setSubscription(channel: String?, status: String?)
Joryio.addToSubscriptionGroup(groupId: String?, channel: String?)
Joryio.removeFromSubscriptionGroup(groupId: String?, channel: String?)
Joryio.unregisterPush()

// In-app messages
Joryio.getInstance().setInAppMessageCallback(callback: ((InAppCampaign) -> Unit)?, capabilities: List<String>? = null)
Joryio.clearInAppMessageCallback() // hands rendering back to the SDK
Joryio.getInstance().setInAppPresenter(presenter: InAppMessagePresenter?)
Joryio.syncInAppCampaigns()
Joryio.getInstance().trackInAppImpression(campaignId: String?, action: String?, elementId: String? = null)
Joryio.getInstance().submitInAppForm(campaignId: String?, values: Map<String, Any?>?, onResult: ((String) -> Unit)?)
Joryio.resetDisplayedCampaigns() // testing only: needs enableDebug
Joryio.getInstance().evaluateInAppCampaigns() // testing only: needs enableDebug

// Push
Joryio.getInstance().registerPushToken(token: String?, onResult: ((PushRegistrationResult) -> Unit)? = null)
Joryio.getInstance().unregisterPush()
Joryio.getInstance().isPushEnabled(): Boolean
Joryio.getInstance().getDeviceToken(): String?
Joryio.getInstance().getPushPermissionStatus(): PushPermissionStatus
Joryio.getInstance().requestPushPermission(activity: Activity?, callback: ((PushPermissionStatus) -> Unit)? = null)
Joryio.getInstance().reportPushDelivered(trackingId: String?) // only with your own messaging service
Joryio.getInstance().trackPushClick(trackingId: String?) // only when you handle the tap yourself

// SDK authentication
Joryio.setSdkAuthenticationToken(token: String?)
Joryio.setSdkAuthenticationErrorHandler(handler: ((SdkAuthError) -> Unit)?)

// Diagnostics
Joryio.getInstance().getQueueSize(): Int
Joryio.getInstance().apiHost: String
Joryio.getInstance().currentApiEndpoint: String
Joryio.getInstance().lastTransportError: JoryioTransportError?

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
  5. Look up the error from Send test push in Getting your Firebase (FCM) credentials

Build Errors​

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

Release builds (R8) and backup​

  • R8 / ProGuard: both artifacts ship their own rules, applied automatically when your app sets isMinifyEnabled = true (R8 full mode included). You do not add rules, and an app without firebase-messaging still builds - push is simply off.
  • Backup: nothing is required. The event queue lives in the app's no-backup directory, and an encrypted store restored from a backup (which never includes the Keystore key) is detected and recreated, still encrypted. If your app declares its own backup rules and you want the SDK's files excluded explicitly, reference @xml/joryio_backup_rules (android:fullBackupContent) and @xml/joryio_data_extraction_rules (android:dataExtractionRules), or copy their lines into your rules. The SDK does not set these attributes itself, so it never conflicts with your manifest.

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.

A purchase, with the keys revenue reporting reads:

import io.joryio.sdk.ecommerce.EcommerceCartItem
import io.joryio.sdk.ecommerce.EcommerceOrder

Joryio.getInstance().ecommerce().purchase(
EcommerceOrder(
orderId = "ORD-1001",
items = listOf(EcommerceCartItem(productId = "SKU-1", name = "Blue Shirt", price = 29.99, quantity = 2)),
value = 59.98,
currency = "USD",
)
)

This sends Order Completed with orderId and total. If you track a purchase by hand, use those two keys: an order sent with order_id or value is recorded with no revenue.

A purchase with no orderId, a value that is not a finite number, or an item without a productId is ignored with a log line, never recorded as a zero order, and no tracker method throws. EcommerceTracker.create() works before initialize(): its events wait for the SDK.

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​