Skip to content
Back to developers
SAMPLE GUIDE · ANDROID

Your Android paywall, server-driven.

onlang-kit renders ONLang flows with your own Android Views, over DivKit: no web views, no downloaded code. This sample takes the paywall you ship today and serves it through ONLang, with today’s screen as the fallback.

YOU NEED
  • An ONLang project: onlang init in your app’s repository
  • Your app’s publishable key (onl_pk_…) and the delivery URL
  • Android 6.0+ (minSdk 23), Kotlin, Gradle
  • The paywall you ship today: it stays your screen when ONLang is switched off
  1. 01

    Add the dependencies

    onlang-kit renders flows over DivKit. Add onlang-billing-play for Google Play Billing (or onlang-billing-revenuecat), and onlang-compose if your paywall lives in Jetpack Compose.

    app/build.gradle.kts
    dependencies {
        implementation("io.onlang:onlang-kit:1.0.0")
        implementation("io.onlang:onlang-billing-play:1.0.0")   // or onlang-billing-revenuecat
        implementation("io.onlang:onlang-compose:1.0.0")        // optional
    }

    The artifacts are distributed to early-access teams. They are commercial software under the ONLang SDK License: free for 14 days per app, then 0.49% of every order or $49 a month.

  2. 02

    Describe your components

    The registry is the contract between your app and the server: which components exist, which props they take, and how long a text may be. The compiler checks every hypothesis against it.

    onlang/registry.json
    {
      "registry": "acme_fitness",
      "version": "1.0.0",
      "products": ["annual", "monthly"],
      "components": {
        "PaywallIntro": { "props": {
          "headline": { "type": "token", "max_chars": 40 },
          "body": { "type": "token", "max_chars": 90 } } },
        "PlanToggle": { "props": {
          "products": { "type": "products", "min_items": 2, "max_items": 2 },
          "default": { "type": "product" } },
          "interactive": true, "emits": ["select"] },
        "PurchaseButton": { "props": {
          "title": { "type": "token", "max_chars": 28 } } }
      }
    }
  3. 03

    Register them in the app

    Each registered component is your own View, built from the props the server chose. Props arrive as a JSONObject, with store prices filled in and copy in the user’s locale.

    OnlangRegistry.kt
    import io.onlang.kit.OnlangRegistry
    
    val registry = OnlangRegistry()
        .register("PaywallIntro") { props, ctx ->
            AcmeIntro(ctx.android).apply {
                title = props.getString("headline"); body = props.getString("body")
            }
        }
        // Interactive: handles its own touches, and reports them to ONLang
        .register("PlanToggle", interactive = true) { props, ctx ->
            AcmePlanToggle(ctx.android, props.getJSONArray("products")).apply {
                onSelect = { plan -> ctx.fire("onlang://select?product=$plan") }
            }
        }
        // Not interactive: no click listener of its own. ONLang wraps it and routes the tap
        .register("PurchaseButton") { props, ctx ->
            AcmeButtonLabel(ctx.android).apply { text = props.getString("title") }
        }
  4. 04

    Configure once, in your Application

    Billing and prices stay yours. PlayBilling buys through Google Play Billing and fills in {{price.annual}} and friends from Play; map registry product names to Play product ids if they differ.

    App.kt
    import io.onlang.billing.PlayBilling
    import io.onlang.kit.Onlang
    import io.onlang.kit.OnlangConfig
    
    class App : Application() {
        override fun onCreate() {
            super.onCreate()
            val billing = PlayBilling(this, productIds = mapOf(
                "annual" to "pro_annual",
                "monthly" to "pro_monthly",
            ))
            Onlang.configure(
                this,
                OnlangConfig(
                    app = "acme_fitness",
                    baseUrl = "https://delivery.example.com",
                    sdkKey = "onl_pk_…",                    // publishable: it ships in the app
                    legalUrls = mapOf("terms" to "https://acme.example/terms",
                                      "privacy" to "https://acme.example/privacy"),
                ),
                registry, purchases = billing, store = billing,
            )
        }
    }

    Purchases carry the install id as their obfuscated account id, so Play renewals and refunds reach the experiment that showed the paywall. With RevenueCat, RevenueCatPurchases sets the subscriber attribute onlang_install_id. If you buy outside ONLang, pass Onlang.installIdOrNull the same way.

    Users in Russia: during the mobile-internet whitelist restrictions, phones on mobile data can’t reach the main hosts. Set baseUrl to https://delivery.ru.onlang.io, ONLang’s node in Moscow: same key, releases and experiments, and paywalls already seen keep loading.

  5. 05

    Show a placement

    On FAILED or KILLED, show your own paywall: that is your fallback when nothing can load, and when the placement is switched off.

    PaywallActivity.kt
    import io.onlang.kit.FlowResult
    import io.onlang.kit.OnlangFlowListener
    import io.onlang.kit.OnlangFlowView
    
    val view = OnlangFlowView(this)
    view.listener = object : OnlangFlowListener {
        override fun onFlowFinished(view: OnlangFlowView, result: FlowResult) {
            // PURCHASED, RESTORED, CLOSED, COMPLETED, FAILED, KILLED
            when (result.outcome) {
                FlowResult.Outcome.FAILED, FlowResult.Outcome.KILLED -> showClassicPaywall()
                else -> finish()
            }
        }
    }
    setContentView(view)
    view.show("paywall")
    
    // Jetpack Compose (onlang-compose)
    OnlangFlow(placement = "paywall", onFinished = { result -> onPaywallDone(result) })
  6. 06

    Bundle the first launch

    So the very first launch works offline, put assets/onlang/<placement>.<locale>.json in the app: a JSON array of the flow’s bundles. onlang compose writes them, one file per screen. After the first launch, the SDK caches what delivery serves.

    terminal
    onlang compose --placement paywall --locale en --out build/onlang/paywall
    # build/onlang/paywall/<screen>.bundle.json → app/src/main/assets/onlang/paywall.en.json (an array)
  7. 07

    Write a hypothesis, publish, experiment

    Hypotheses are code in your repository. onlang lint compiles every combination and checks it (lengths, price disclosure, legal links) before anything ships.

    terminal
    onlang hypothesis create --id paywall.cta.copy --kind tokens --screen paywall \
      --key cta.title --rationale "Name the outcome, not the step" \
      --variant "outcome=Get my plan" --variant "trial=Try Pro free for {{trial.annual}}"
    onlang lint                                   # every assignment compiled and checked
    ONLANG_TOKEN=onl_sk_… onlang publish          # delivery serves the release at once
    ONLANG_TOKEN=onl_sk_… onlang experiment create --placement paywall --holdout 0.1 --start
    ONLANG_TOKEN=onl_sk_… onlang experiment status <id>   # uplift vs holdout, 95% interval
  8. 09

    Know where the off switch is

    One command takes the placement off ONLang. Delivery answers within a second, and the flow finishes with KILLED, even on devices that go offline afterwards: neither the cache nor the bundled flow is shown, so users see your own paywall until you switch it back on.

    terminal
    onlang kill paywall --reason "INC-12: CTA does nothing on Android 15"
    onlang kill --status
    onlang kill paywall --lift

What the SDK does for you

  • Loads network → cache → the bundled flow; bundles are content-addressed and verified (SHA-256)
  • Renders your registered Views with DivKit: no web views, no downloaded code
  • Records impressions, dwell and scroll depth, taps with rage and dead clicks, plan switches and purchases
  • Queues events on the device and uploads them with WorkManager, batched and retried; ingest drops duplicates

Rules of thumb

  • Billing, prices and entitlement stay in your app; ONLang never charges anyone
  • A component that isn’t interactive must not set its own click listener
  • Copy that has to be true about the device or the account belongs in a component, not a token
  • Treat FAILED and KILLED the same: show your own paywall

The kit suite renders the same 45 golden and fuzz bundles as the iOS and React Native SDKs on a device, with pixel snapshots, and runs the consent-gated WorkManager upload; the billing adapters run against fake Play and RevenueCat clients. onlang-kit adds 234 KB to a release app over DivKit.

Bring your Android paywall.

Early-access teams get the SDK, the CLI and a hand with the first placement.

Request early access