A/B Testing Android SDK
Native Kotlin SDK for A/B experiments, feature flags, and event tracking on Android. Signed user tokens, offline-first event queue with SharedPreferences persistence, 17 built-in event types.
Native Kotlin SDK for A/B testing, feature flags, and event tracking on Android. Server-side variant assignment, an offline-first event queue that survives app restarts, and 17 built-in event types. Anything done on behalf of a user needs a signed user token; you can initialize the SDK before sign-in and it waits for the token. Installs from Abrova Center.
- Android SDK 21 (Lollipop) or higher.
- Kotlin 1.9.0 or higher.
- Android Gradle Plugin 8.0 or higher.
- A backend endpoint that returns an Abrova user token for the signed-in user (see step 3, User tokens).
Installation#
Install from Abrova Center in two steps. First add the Abrova Center repository in settings.gradle (or the project build.gradle):
repositories {
google()
mavenCentral()
maven { url "https://center.abrova.ir/api/packages/abrova/maven" }
}Then add the dependency in your app's build.gradle.kts:
dependencies {
implementation("ir.abrova:abrova-ab-testing-android:0.2.3")
}Add Internet permission
In your AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
Initialization#
Initialize AbrovaAbTesting once, in your Application class. Pass a tokenProvider so the SDK can get a user token from your backend.
import android.util.Log
import ir.abrova.abtesting.Experiment
import ir.abrova.abtesting.AbrovaAbTesting
import ir.abrova.abtesting.AbrovaAbTestingCallback
import ir.abrova.abtesting.AbrovaAbTestingConfig
import ir.abrova.abtesting.AbrovaAbTestingError
import ir.abrova.abtesting.AbrovaTokenProvider
import ir.abrova.abtesting.models.FeatureFlag
val config = AbrovaAbTestingConfig(
apiKey = "YOUR_API_KEY",
// Runs on a background thread, so a blocking call to your backend is fine.
// Return null while no one is signed in.
tokenProvider = AbrovaTokenProvider { myBackend.fetchAbrovaTokenBlocking() },
debug = BuildConfig.DEBUG,
// flushInterval = 10000L, // Your own send interval in ms. Leave it out to follow the console
maxQueueSize = 100 // Max events before auto-flush
)
AbrovaAbTesting.init(this, config, object : AbrovaAbTestingCallback {
override fun onInitialized() {
Log.d("Abrova", "SDK initialized")
}
override fun onExperimentAssigned(experimentKey: String, variantKey: String, config: Map<String, Any>?) {
Log.d("Abrova", "Assigned: $experimentKey -> $variantKey")
}
override fun onExperimentsRefreshed(experiments: List<Experiment>) {
Log.d("Abrova", "Refreshed ${experiments.size} experiments")
}
override fun onFeatureFlagsRefreshed(flags: List<FeatureFlag>) {
Log.d("Abrova", "Refreshed ${flags.size} flags")
}
override fun onSyncCompleted(synced: Int, failed: Int, pending: Int) {
Log.d("Abrova", "Synced: $synced, failed: $failed, pending: $pending")
}
override fun onOnlineMode() {
Log.d("Abrova", "Back online")
}
override fun onOfflineMode() {
Log.d("Abrova", "Offline")
}
override fun onError(error: AbrovaAbTestingError) {
Log.e("Abrova", "Error: ${error.message}")
}
})The callback is optional and every method has an empty default, so override only what you need. Callbacks run on the main thread. You can add or remove listeners later with addCallback() and removeCallback().
onSyncCompleted is called each time queued events have been sent: synced is the number the service stored, failed the number it turned down, and pending the number still waiting on the device.
No SDK method throws when you call it before init, before the first load has finished or before a user is signed in: getters return the default you pass and the track… methods do nothing. If the first load fails (no network), the SDK works from its cache and loads again when the network is back.
Configuration
| Option | Type | Required | Description |
|---|---|---|---|
| apiKey | String | - | Your project API key (required) |
| tokenProvider | AbrovaTokenProvider? | null | Returns a user token minted by your backend, or null while no one is signed in. Required for assigning variants, tracking and server flag evaluation |
| userToken | String? | null | A token you already hold. Prefer tokenProvider: a fixed token stops working when it expires. Hand over a newer one with setUserToken() |
| debug | Boolean | false | Log to Logcat. Logs never include your API key or request bodies |
| flushInterval | Long | 30000 | How often queued events are sent, in milliseconds. Left at the default, the SDK follows the Send interval set in the Abrova console (see below) |
| maxQueueSize | Int | 100 | Queued events that trigger a flush |
Send interval and list cache
Two values come from your project's settings in the Abrova console (A/B Testing › Settings › Defaults):
- Send interval — how often queued events are sent. The SDK follows it unless you set your own
flushInterval. On Android, any value other than the default,AbrovaAbTestingConfig.DEFAULT_FLUSH_INTERVAL(30 seconds), is your own and is always used. A config that sets exactly30000Lis treated as the default and follows the console. - List cache — how long the SDK uses the experiment list before it asks the service again. There is no SDK option for it. Once the list is older than that, the SDK fetches it again in the background the next time your code reads a variant or tracks an event. The call doesn't wait for it, and a failed refresh is not reported to
onError.
A change you make in the console (a new experiment, changed weights or traffic) reaches running apps within the list cache time, without a restart. A user who already has a variant keeps it. A changed send interval is picked up at that same refresh. The SDK keeps the console's values within safe limits, and sends every 30 seconds until it knows the console's value. Apps on SDK versions older than 0.2.3 keep their built-in behaviour.
User tokens#
The API key ships inside your app, so it can't prove which user a request is for. A user token can. Your backend mints it with your project's server secret, the SDK sends it with each request, and Abrova takes the user from the token.
A token is required to get a variant from the server, track events and conversions, sync queued events, and evaluate flags on the server. Loading the experiment and flag lists needs only the API key, so your app can fetch them before anyone signs in.
Mint the token on your backend
Add one endpoint, protected by your own login, that calls Abrova and returns the token to the signed-in user:
curl -X POST https://auth.abrova.ir/users/token \
-H "x-api-key: $ABROVA_API_KEY" \
-H "x-server-secret: $ABROVA_SERVER_SECRET" \
-H "Content-Type: application/json" \
-d '{"userId": "user-123", "expiresIn": 3600}'
# → { "token": "eyJhbGciOi…", "userId": "user-123", "expiresIn": 3600, "expiresAt": "…" }expiresIn is in seconds: 3600 (1 hour) by default, 86400 (24 hours) maximum. The service takes the user from the token, so if you call setUserId() in the app, pass the same userId.
The server secret can mint a token for any user. Keep it on your server, out of your APK, app bundle and source code.
One token for A/B Testing, Chat and Sync
This is the same Abrova user token that Abrova Chat and Abrova Sync use. If your app uses more than one of them, mint one token and give it to every SDK's tokenProvider instead of fetching one per product.
Give the token to the SDK
AbrovaTokenProvider.fetchToken() runs on a background thread, so it may block (for example, a synchronous HTTP call to your backend). The SDK caches the token, asks for a new one shortly before it expires, and asks once more if Abrova reports it expired.
val tokenProvider = AbrovaTokenProvider {
// No one signed in: return null (or throw). The SDK then waits for a token.
val session = sessionStore.current() ?: return@AbrovaTokenProvider null
myBackend.fetchAbrovaTokenBlocking(session)
}If you would rather hand tokens over yourself, leave tokenProvider out and call AbrovaAbTesting.setUserToken(token) after sign-in and whenever you refresh the token. A token passed as userToken in the config works the same way: it isn't refreshed, so hand over a newer one before it expires.
Before your user signs in
You can call init before anyone is signed in. Without a token the SDK waits instead of failing:
AbrovaAbTesting.isAwaitingUserTokenistrue, andonAwaitingUserTokenis called once, on the main thread, each time the SDK starts waiting.getVariantandgetVariantAsyncreturn the default you pass.- Nothing is sent for a user and no error is reported. Events you track are kept on the device (at most 1,000).
The SDK carries on by itself as soon as it has a token: it fetches the variants your app asked for (onExperimentAssigned is called for each) and sends the kept events.
AbrovaAbTesting.onAwaitingUserToken = { /* e.g. show the default experience */ }
// After sign-in
AbrovaAbTesting.refreshUserToken() // with a tokenProvider: asks it again now
AbrovaAbTesting.setUserToken(token) // without one: hand the token over
// After sign-out
AbrovaAbTesting.clearUserId() // or setUserToken(null)Events are credited to the user in the token. Events recorded before sign-in are only sent if they were recorded for the same user id that signs in, so call setUserId() with the id your backend puts in the token before you track. Events recorded for an anonymous user are not credited to anyone and are dropped at sign-in.
A token is valid until it expires, so store it with the user's session (Keystore or encrypted storage) and reuse it across app launches. Your tokenProvider then only calls your backend when the stored token is close to expiry, not on every start.
User Identification#
The user token decides who the user is: once the SDK has a token, it acts as the user that token was minted for. Call setUserId() with that same ID when a user signs in, and clearUserId() when they sign out. Until there is a user, the SDK uses a random ID it generates on first launch.
// After login — use the same ID your backend mints the token for
AbrovaAbTesting.setUserId("user-123")
AbrovaAbTesting.refreshUserToken() // or setUserToken(token) if you hand tokens over yourself
// After logout — forget the user and wait for the next token
AbrovaAbTesting.clearUserId()
// Get current user ID
val userId = AbrovaAbTesting.getUserId()
// Set user attributes for targeting rules
AbrovaAbTesting.setUserAttributes(mapOf(
"plan" to "premium",
"country" to "US",
"age" to 25
))A different user ID (setUserId()), a token for another user, or a sign-out (clearUserId(), setUserToken(null)) is a user switch. The SDK clears the previous user's variants, attributes, token and pending events before it does anything for the next user. Events of the previous user that are ready to send go out once more under that user's own token, never under another user's. Set the user attributes again after a switch.
Experiments#
Get the variant assigned to the current user for an experiment. The variant always comes from the service (this needs a user token) and is then kept on the device; the SDK never picks one itself.
// Asynchronous (recommended) — waits for the service's answer;
// the result is delivered on the main thread
AbrovaAbTesting.getVariantAsync("checkout-redesign") { variant ->
when (variant) {
"variant-a" -> showNewCheckout()
"variant-b" -> showMinimalCheckout()
else -> showOriginalCheckout() // control
}
}
// Synchronous — never blocks, safe on the main thread
val variant = AbrovaAbTesting.getVariant(
experimentKey = "checkout-redesign",
defaultVariant = "control" // returned until the service has answered
)
// Get variant config (colors, text, etc.) — available once a variant is assigned
val config = AbrovaAbTesting.getVariantConfig("checkout-redesign")
val buttonColor = config?.get("button_color") as? String ?: "#6B7280"
val layout = config?.get("layout") as? String ?: "default"
// List all experiments (API key only)
val experiments = AbrovaAbTesting.getExperiments()
// Refresh from server
AbrovaAbTesting.refreshExperiments()getVariant returns the variant the service assigned earlier. The first time you ask for an experiment it returns defaultVariant at once and fetches the assignment in the background; onExperimentAssigned tells you when it has arrived. Use getVariantAsync the first time a screen needs the variant, otherwise the user may briefly see the default before their real variant.
You get defaultVariant back when the experiment isn't running, the user doesn't match its targeting rules, there is no user token yet, or the service can't be reached and nothing is stored. A user the service keeps out of an experiment (targeting, traffic allocation) gets the control variant. Tracking calls are always safe to make: you don't need to check whether the user is in the experiment before you track. The SDK sends these events like any other and the service decides what counts; they never enter the experiment's results.
Feature Flags#
Toggle features remotely with targeting rules and rollout percentages.
// Local evaluation against the cached flag list
val darkMode = AbrovaAbTesting.isFeatureEnabled("dark-mode")
// Server-side evaluation (needs a user token; falls back to local evaluation)
AbrovaAbTesting.isFeatureEnabledAsync("dark-mode") { enabled ->
if (enabled) enableDarkMode()
}
// Get feature flag value
val maxUpload = AbrovaAbTesting.getFeatureValue("max-upload-size", defaultValue = 10)
// Get all flags
val flags = AbrovaAbTesting.getFeatureFlags()
flags.forEach { flag ->
Log.d("Abrova", "${flag.key}: enabled=${flag.enabled}, rollout=${flag.rolloutPercentage}%")
}
// Refresh flags from server
AbrovaAbTesting.refreshFeatureFlags()Event Tracking#
Track user interactions with 17 built-in event types. Events are credited to the variant the user was assigned, so call getVariant() or getVariantAsync() for an experiment before tracking events for it. Events tracked while the assignment is still loading (or while the SDK waits for a user token) are kept and sent once the variant is known; events for an experiment you never asked a variant for are dropped.
Core Events
AbrovaAbTesting.trackView("experiment-key")
AbrovaAbTesting.trackClick("experiment-key")
AbrovaAbTesting.trackConversion("experiment-key", 500000.0)
AbrovaAbTesting.trackCustomEvent(
"experiment-key",
"button_hover",
mapOf("duration_ms" to 1500, "element" to "cta_button")
)Conversion Events
AbrovaAbTesting.trackAddToCart("experiment-key", 300000.0, "sku-123", mapOf("quantity" to 2))
AbrovaAbTesting.trackRemoveFromCart("experiment-key", 300000.0, "sku-123")
AbrovaAbTesting.trackBeginCheckout("experiment-key", 1500000.0)
AbrovaAbTesting.trackPurchase("experiment-key", 1250000.0, "txn-12345", mapOf("currency" to "Toman"))Engagement Events
AbrovaAbTesting.trackScroll("experiment-key", 75.0)
AbrovaAbTesting.trackFormSubmit("experiment-key", "signup-form")
AbrovaAbTesting.trackSearch("experiment-key", "shoes")
AbrovaAbTesting.trackShare("experiment-key", "twitter")Media & Auth Events
AbrovaAbTesting.trackVideoStart("experiment-key", "vid-001")
AbrovaAbTesting.trackVideoComplete("experiment-key", "vid-001")
AbrovaAbTesting.trackSignUp("experiment-key", "google")
AbrovaAbTesting.trackLogin("experiment-key", "email")
AbrovaAbTesting.trackLogout("experiment-key")Generic Event
import ir.abrova.abtesting.models.EventType
AbrovaAbTesting.track(
experimentKey = "experiment-key",
eventType = EventType.CUSTOM,
eventName = "page_load_time",
eventValue = 2.3,
properties = mapOf("page" to "/checkout", "cached" to false)
)Offline Support#
Events are queued when offline and synced when connectivity returns. Experiments, feature flags and assignments are cached locally. Queued events persist across app restarts via SharedPreferences.
The queue holds at most 1,000 events; when it is full, the oldest are dropped first. Events are sent 100 at a time. When the service can't be reached, asks the SDK to slow down, or your project has reached its usage limit, the SDK waits before it tries again. Each event carries the time your app recorded it, so events sent together keep their order.
// Check online status val online = AbrovaAbTesting.isOnline // Get pending event count val pending = AbrovaAbTesting.getPendingEventCount() // Force sync pending events AbrovaAbTesting.flush()
Lifecycle & Sync#
class MyActivity : AppCompatActivity() {
override fun onStop() {
super.onStop()
AbrovaAbTesting.flush() // Sync events when activity stops
}
override fun onResume() {
super.onResume()
AbrovaAbTesting.refreshExperiments() // Refresh on resume
}
}
// Clear cached experiments, flags, assignments and the token.
// Mainly for tests — call init() again before using the SDK.
AbrovaAbTesting.reset()
// Stop the event queue and network listener
AbrovaAbTesting.dispose()To switch users, use setUserId() on login and clearUserId() on logout rather than reset().
EventType Enum#
| Option | Type | Required | Description |
|---|---|---|---|
| VIEW | - | - | view |
| CLICK | - | - | click |
| CONVERSION | - | - | conversion |
| CUSTOM | - | - | custom |
| SCROLL | - | - | scroll |
| FORM_SUBMIT | - | - | form_submit |
| SEARCH | - | - | search |
| SHARE | - | - | share |
| ADD_TO_CART | - | - | add_to_cart |
| REMOVE_FROM_CART | - | - | remove_from_cart |
| BEGIN_CHECKOUT | - | - | begin_checkout |
| PURCHASE | - | - | purchase |
| VIDEO_START | - | - | video_start |
| VIDEO_COMPLETE | - | - | video_complete |
| SIGN_UP | - | - | sign_up |
| LOGIN | - | - | login |
| LOGOUT | - | - | logout |
API Reference#
| Option | Type | Required | Description |
|---|---|---|---|
| init(context, config, callback?) | - | - | Initialize the SDK |
| setUserId(id) | - | - | Set user ID; call on login |
| clearUserId() | - | - | Sign-out: forget the user and wait for a token |
| setUserToken(token) | - | - | Hand over a user token (null after sign-out) |
| refreshUserToken() | - | - | Ask the tokenProvider again now (after sign-in or sign-out) |
| isAwaitingUserToken | - | - | true while the SDK waits for a user token |
| onAwaitingUserToken | - | - | Called once each time the SDK starts waiting |
| getUserId() | - | - | Get current user ID |
| setUserAttributes(attrs) | - | - | Set targeting attributes |
| getVariant(key, default) | - | - | Get assigned variant (never blocks; default until the service has answered) |
| getVariantAsync(key, default, cb) | - | - | Get assigned variant (waits for the service) |
| getVariantConfig(key) | - | - | Get variant configuration |
| isFeatureEnabled(key, default) | - | - | Check flag (local evaluation) |
| isFeatureEnabledAsync(key, default, cb) | - | - | Check flag (server evaluation) |
| getFeatureValue(key, default) | - | - | Get feature flag value |
| getFeatureFlags() | - | - | Get all feature flags |
| refreshFeatureFlags() | - | - | Refresh flags from server |
| getExperiments() | - | - | Get all experiments |
| refreshExperiments() | - | - | Refresh experiments from server |
| track(key, type, name?, value?, props?) | - | - | Track any event type |
| addCallback(cb) / removeCallback(cb) | - | - | Add or remove an SDK listener |
| isOnline | - | - | Whether the device is online |
| getPendingEventCount() | - | - | Number of queued events |
| flush() | - | - | Force sync pending events |
| reset() | - | - | Clear all state, cache and token |
| dispose() | - | - | Release resources |
Complete Example#
Full integration with user tokens, experiments, feature flags, and event tracking:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
AbrovaAbTesting.init(this, AbrovaAbTestingConfig(
apiKey = "YOUR_API_KEY",
// Returns null while no one is signed in
tokenProvider = AbrovaTokenProvider { myBackend.fetchAbrovaTokenBlocking() }
))
}
}
// In your login flow, once your backend knows the user
fun onLoggedIn(user: User) {
AbrovaAbTesting.setUserId(user.id) // the ID your backend puts in the token
AbrovaAbTesting.refreshUserToken() // asks tokenProvider again now
AbrovaAbTesting.setUserAttributes(mapOf("plan" to user.plan, "country" to user.country))
}
// In your logout flow
fun onLoggedOut() {
AbrovaAbTesting.clearUserId()
}
class CheckoutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_checkout)
// Check feature flag
if (AbrovaAbTesting.isFeatureEnabled("dark-mode")) applyDarkTheme()
// Get experiment variant, then track the view
AbrovaAbTesting.getVariantAsync("checkout-redesign") { variant ->
val config = AbrovaAbTesting.getVariantConfig("checkout-redesign")
setupCheckout(variant, config)
AbrovaAbTesting.trackView("checkout-redesign")
}
}
private fun setupCheckout(variant: String, config: Map<String, Any>?) {
val button = findViewById<Button>(R.id.checkoutButton)
button.text = config?.get("text") as? String ?: "Complete Purchase"
val colorHex = config?.get("color") as? String ?: when (variant) {
"variant-a" -> "#22C55E"
"variant-b" -> "#3B82F6"
else -> "#6B7280"
}
button.setBackgroundColor(Color.parseColor(colorHex))
button.setOnClickListener {
AbrovaAbTesting.trackClick("checkout-redesign")
processCheckout()
}
}
private fun processCheckout() {
// ... checkout logic ...
AbrovaAbTesting.trackPurchase("checkout-redesign", orderTotal, "txn-123")
}
override fun onStop() {
super.onStop()
AbrovaAbTesting.flush()
}
}ProGuard / R8 Configuration#
The SDK ships consumer rules that keep its classes, so most apps need no extra configuration. If your own rules strip generic signatures or annotations that Gson relies on, add:
# Gson (used by AbrovaAbTesting)
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.google.gson.** { *; }