announcements

Introducing the Zenmanage Android SDK

The Zenmanage Android SDK is a Kotlin library on Maven Central that evaluates flags on the device, caches rules in SharedPreferences, and keeps percentage rollouts stable across launches.

The Zenmanage Android SDK is a Kotlin library on Maven Central. Add one Gradle line, read flags on the device against rules cached in SharedPreferences, and keep a rollout's users in the same bucket from one launch to the next.

Shipping a web feature is a deploy. Shipping an Android feature is a release build, a store review, and then a wait while people update. Some never do. The app you shipped last spring is still on someone's phone, running whatever logic you compiled into it, and a bad change can't be rolled back from your side.

A flag gives you a lever that doesn't depend on the store. Wrap the new checkout in one, start it at 1%, and move the percentage up from the dashboard once the crash reports look clean. The code that reads the flag doesn't change between those steps. The Android SDK is built for the parts of that which are different on a phone: the network drops, the app gets killed in the background, and the rules you need on the next launch might be an hour old.

What it is

zenmanage-kotlin evaluates flags locally. It fetches your rules, then reads each flag against them on the device, so a read never waits on a per-flag network call. It's the same local-evaluation model as our Java and iOS SDKs, and it evaluates rules in the same order as our Swift, PHP, Ruby, and Flutter SDKs. It requires API level 21 or later and JDK 11 or later, and it's written in Kotlin with a synchronous, builder-based API that Java code can call too.

Getting started

Add the dependency. The SDK is on Maven Central, so no extra repository is needed:

dependencies {
    implementation("com.zenmanage:zenmanage-kotlin:1.0.0")
}

Create one Zenmanage when the app starts and share it. It keeps the loaded rules in memory, so building it per screen throws that work away. An Application subclass is a good place for it. This version also stores the rules in SharedPreferences, so the next cold launch can start from them:

import android.app.Application
import com.zenmanage.kotlin.Zenmanage
import com.zenmanage.kotlin.cache.SharedPreferencesCache
import com.zenmanage.kotlin.config.ConfigBuilder

class ShopApplication : Application() {
    lateinit var zenmanage: Zenmanage
        private set

    override fun onCreate() {
        super.onCreate()

        val prefs = getSharedPreferences("zenmanage", MODE_PRIVATE)
        val config = ConfigBuilder.create()
            .withEnvironmentToken("mob_your_mobile_key_here")
            .withCache(SharedPreferencesCache(prefs))
            .build()

        zenmanage = Zenmanage(config)
    }
}

Use a mobile key (mob_) in the app. Anything in an APK can be pulled out by someone with the file, so the key you ship should only be able to read flags. Client keys (cli_) are rejected.

Read a flag without blocking the main thread

Reads are synchronous. Once the rules are loaded, a read is a local lookup. The first read on a cold cache is different: it fetches the rules over the network on the calling thread, and a slow connection can hold that thread for up to the 10-second request timeout. Make that first read off the main thread, then update the UI with the result:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

class CheckoutActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val zenmanage = (application as ShopApplication).zenmanage

        lifecycleScope.launch {
            // The default is what users see when the flag is missing or the rules can't load.
            val enabled = withContext(Dispatchers.IO) {
                zenmanage.flags().single("new-checkout", false).isEnabled()
            }

            showCheckout(newCheckout = enabled)
        }
    }
}

The default you pass to single is the safety net. If the flag doesn't exist, or the rules can't be loaded because the device is offline with nothing cached, single returns that default and doesn't throw. A flag outage shows people the old checkout, not a crash.

Phased rollouts that stay phased

A phased rollout on a phone is risk management. You want a small group to hit any problem before everyone else does, and you want to be able to widen or stop the rollout without a new build. Set the percentage on the flag, and pass the app a stable identifier for the user:

import com.zenmanage.kotlin.context.Context

val context = Context.single("user", userId)

val inRollout = zenmanage.flags()
    .withContext(context)
    .single("new-checkout", false)
    .isEnabled()

The SDK hashes the rollout's salt together with that identifier using CRC32 and maps the result to a bucket from 0 to 99. The user is in the rollout when their bucket falls below the percentage. The same identifier and salt always give the same bucket, so a user stays in or out across launches, and raising the percentage only adds people. The hash is the same one our other SDKs use, so a user who got the new checkout on their phone sees it on your web app too. The bucketing post explains why that stability matters, and the progressive rollouts post covers the stages themselves.

Use an identifier that doesn't change when a user reinstalls the app or signs in on a new device. A random ID generated on each install would move people between buckets, and a rollout that reshuffles its users is hard to reason about.

Try a UI variant on some users

A flag doesn't have to be a boolean. Make it a string, give each layout a value, and let the value choose the screen. Attributes on the context let your rules target by plan, country, or anything else you pass in:

import com.zenmanage.kotlin.context.Attribute
import com.zenmanage.kotlin.context.Context

val context = Context.single("user", userId, "Taylor")
    .addAttribute(Attribute("country", listOf("US")))

val layout = zenmanage.flags()
    .withContext(context)
    .single("checkout-layout", "control")
    .asString()

when (layout) {
    "one-page" -> showOnePageCheckout()
    else -> showMultiPageCheckout()
}

Zenmanage decides who sees which layout. It doesn't analyze the outcome. Percentage rollouts are a release control, not an experiment platform, so we don't compute significance for you. Send the variant name to your analytics with the conversion event and judge the result there. Always give single a sensible default, such as "control" here, because that's what a user sees when the app can't reach the rules.

Rules that survive a cold launch

A phone starts in places a server never does: a train, a hotel with a captive portal, airplane mode. With the SharedPreferences cache, the SDK behaves like this:

  1. On the first read, it fetches your rules and writes them to SharedPreferences with an expiry time.
  2. On the next cold launch, it reads the saved rules and evaluates against them with no fetch, until the cache expires.
  3. If the cache has expired and the network is down, the read falls back to the default you passed.

The default lifetime is 3600 seconds. Change it with withCacheTtlSeconds, and call refreshRules() when you want fresh rules now instead of waiting for expiry:

val config = ConfigBuilder.create()
    .withEnvironmentToken("mob_your_mobile_key_here")
    .withCache(SharedPreferencesCache(prefs))
    .withCacheTtlSeconds(900)
    .build()

// Later, for example when the app returns to the foreground.
zenmanage.flags().refreshRules()

If you don't pass a cache, the SDK keeps rules in memory for the life of the process. That's fine for an app that's always in the foreground, but a cold launch will fetch again. For anything else, use SharedPreferencesCache.

Three things to know before you ship

A change reaches a running app when it refreshes

The rules load once per Zenmanage instance. A running app keeps them until you call refreshRules() or the cache expires, so a flag you change in the dashboard reaches a backgrounded app at its next refresh. That's usually what you want, but it means the rollout you started this morning might not be what every session sees until the TTL passes.

No stable identifier means no rollout

A context with no identifier, including the default anonymous one, doesn't land in a percentage rollout. It always gets the default. If your 10% rollout looks like it's doing nothing, check that the context you pass carries the same user ID every time.

The first read on a cold cache waits on the network

The timeouts are 10 seconds, so a dead network can keep a read waiting that long before it falls back to your default. Read off the main thread, as in the example above, and fall back quickly to a default that's safe to show. Warm the cache early if a screen needs a flag right away.

Where to go next

The Android quickstart covers the setup in more detail, including JSON flags and defaults for several flags at once. The zenmanage-kotlin repository has the full README and the examples, and the Maven Central page lists every released version to pin.

If you already use the iOS SDK, the same flags work on both platforms. Read the iOS post for the Swift side of the same rollout story.

Enjoyed this article?

Share it with your network.