Home / Kotlin SDK
Kotlin SDK · Android

ContentFlow Kotlin SDK

A native Kotlin delivery client for ContentFlow: dynamic blocks, translations, identity, consent and engagement analytics, plus a Compose slot that renders a placement wherever you drop it. Package namespace click.contentflow, three modules, and the same wire contract documented in the REST reference.

! Read this before you plan around it: the package has never been compiledThe source is complete and the tests are written, but this package has never been built and never been run. It was written on a machine with no JVM, no Gradle and no Android SDK, so there is zero build verification, zero test execution and no published artifact. The first person to run gradle build should expect to fix small things: an import, a signature, a Gradle or AGP detail. Everything on this page is documented from the source that exists, not from a build that succeeded. See build status for exactly what has and has not happened.

Status

Build status: source complete, not yet built

We would rather tell you this on the first screen than have you discover it in your build log. This page documents a package that exists as source and nothing more.

ThingState
Public API, three modules, wire encoding and decodingWritten, complete, reviewed by reading
Contract tests (envelope, ETag and 304, typed access, consent gating, cache namespacing, collection decoding)Written against an OkHttp MockWebServer, never executed
Gradle build, Kotlin compile, Android buildNever run. No JVM, no Gradle and no Android SDK on the machine it was authored on
Gradle wrapper (gradlew, gradle-wrapper.jar)Not committed. Only gradle/wrapper/gradle-wrapper.properties is present
PublishingNot on Maven Central and not on any other repository. No maven-publish configuration exists in the build at all
Version1.0.0 declared in the root build file, on all three modules, as a matched set

The honest expectation is that the first real build will surface a handful of ordinary compile-time corrections, and that the test suite will need a pass before it goes green. That is normal for code that has never met a compiler. It is not a reason to distrust the design, and it is a very good reason not to schedule a release around it until someone has run the build.

! Do not treat a documented package as an available oneIf you are evaluating ContentFlow for an Android app right now, the shipping path today is a hand-written client against the REST API, or this source consumed as a local project. Plan the migration to a published artifact after the first successful build, not before it.

Getting it into a build today

There is no coordinate to depend on. implementation("click.contentflow:compose:1.0.0") will not resolve from any repository, because nothing has been published and the build declares no publishing at all. Until that changes there are two honest routes.

Route one: a composite build

Point your app's settings.gradle.kts at the checkout. Gradle substitutes the modules in place, so your app compiles against the real source.

settings.gradle.kts (your app)
includeBuild("../contentflow-sdk-kotlin")
build.gradle.kts (your app module)
dependencies {
    implementation("click.contentflow:compose:1.0.0")  // substituted by the composite build
}

Route two: include the modules directly

Copy or submodule the checkout into your repository and include the three Gradle projects, then depend on them as projects.

build.gradle.kts (your app module)
dependencies {
    implementation(project(":compose"))  // transitively brings :android and :core
}

If you are not using Compose, depend on the android module instead and drive ContentFlowClient yourself. core alone is plain Kotlin/JVM with no Android types, which is what makes the contract tests runnable without an emulator.

First build

No wrapper jar is committed, so generate one once with a locally installed Gradle, or open the project in Android Studio and let it provision the wrapper for you.

first build
$ gradle wrapper          # once, because gradlew is not committed
$ ./gradlew :core:test    # JVM contract tests, no emulator needed
$ ./gradlew build         # all three modules, needs the Android SDK
i Expect this to fail the first timeNobody has run these commands yet. Treat the first red build as the start of the work, not as evidence that something is broken beyond repair. The :core:test target is the cheapest place to start because it needs neither the Android SDK nor a device.

Quickstart

Start, register, place, read

Four steps, end to end. The renderer registration in step one is not optional, for the reason explained under placements and renderers.

1. Register a renderer, then start

ContentFlow.start(apiKey, locale) takes no tenant and no context. The tenant is derived from the key, and the application context is captured at process start by ContentFlowInitProvider, which the android module's manifest registers for you.

App.kt
import click.contentflow.ContentFlow
import click.contentflow.registerRenderer
import java.util.Locale

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

        // Register before start so the first delivered block already has a renderer.
        ContentFlow.registerRenderer("home.discovery") { block ->
            DiscoveryCard(block)
        }

        ContentFlow.start(apiKey = "ws_1a2b3c_app", locale = Locale.getDefault())
    }
}

2. Drop the slot into your UI

HomeScreen.kt
import click.contentflow.ContentFlowSlot

@Composable
fun HomeScreen() {
    Column {
        Balance(account = user.account)
        ContentFlowSlot(placement = "home.discovery")
        Transactions()
    }
}

3. Declare typed field keys

There is no code generation in v1. A FieldKey is a typed handle on one tag, declared next to the screen that uses it, so there is no schema download, no build plugin, and no generated model to drift when a marketer edits a block.

DiscoveryFields.kt
import click.contentflow.FieldKeys

object DiscoveryFields {
    val Title  = FieldKeys.text("#title_main")
    val Body   = FieldKeys.textarea("#body_desc")
    val Hero   = FieldKeys.image("#header_image")
}

4. Read fields by tag inside the renderer

DiscoveryCard.kt
import click.contentflow.ContentBlock

@Composable
fun DiscoveryCard(block: ContentBlock) {
    val title = block.value(DiscoveryFields.Title)   // null when absent
    val hero  = block.require(DiscoveryFields.Hero)  // throws when absent or mistyped
    val body  = block.value(DiscoveryFields.Body)

    Card(title = title, hero = hero, body = body)
}

That is the whole integration. The slot serves cached content immediately, revalidates in the background, keeps stale content visible when the network is down, renders nothing when the placement is empty, and shares one observation and one live-updates connection across every slot on screen.


Modules and requirements

Three Gradle modules, one Kotlin package. The Kotlin package is click.contentflow in all three; the Android library namespaces differ, which matters only for generated R classes.

ModuleKindAndroid namespaceContains
corePlain Kotlin/JVMnot an Android libraryContentFlowClient, configuration, content model, typed field keys, errors, diagnostics, the whole transport and cache
androidAndroid library (AAR)click.contentflow.androidThe ContentFlow facade, ContentFlowInitProvider, lifecycle and preferences bindings
composeAndroid library (AAR)click.contentflow.composeContentFlowSlot, registerRenderer, the development preview

core is deliberately free of Android types so envelope decoding, ETag and 304 handling, typed field access, consent gating and cache namespacing are all testable on the JVM without an emulator. compose depends on android, which depends on core, so depending on compose brings all three.

Toolchain

RequirementValue
minSdk24, on both android and compose
compileSdk34
Java / JVM target17 (jvmToolchain(17) on all three modules)
Kotlin2.0.21
Android Gradle Plugin8.5.2
Gradle distribution8.9, per gradle-wrapper.properties
Coroutines1.9.0
kotlinx.serialization JSON1.7.3
OkHttp4.12.0
Compose BOM2024.10.01

Why OkHttp and not Ktor

Three reasons, all of them about your build rather than ours. It is already on nearly every Android classpath through Retrofit or Coil, so it usually adds nothing. It streams server-sent events without a second dependency. And it runs unchanged on the JVM, which is what lets the contract tests use MockWebServer with no emulator. core exposes OkHttp as an api dependency, and ContentFlowClientFactory.create accepts your own OkHttpClient so its dispatcher and connection pool are shared rather than duplicated. Closing a ContentFlow client never shuts down a client you passed in.

The Android module also declares INTERNET and ACCESS_NETWORK_STATE, and registers ContentFlowInitProvider under the authority ${applicationId}.contentflow-init, so two apps on one device never collide. The provider holds the application context and nothing else: no Activity, no Fragment, no View.


Concept

Keys, tenant and environment

One publishable key carries everything: which workspace you read, and whether you read live or test. Nothing else in the SDK can change either.

The tenant is derived from the key

A key is shaped <tenantId>_app for live or <tenantId>_test for test, and the tenant is the part before the suffix. Tenant ids themselves contain underscores, real workspaces look like ws_<hex>, so the SDK strips the known suffix rather than splitting on the first underscore.

KeyDerived tenantEnvironment
ws_1a2b3c_appws_1a2b3clive
ws_1a2b3c_testws_1a2b3ctest

That is why ContentFlow.start(apiKey = ..., locale = ...) needs no tenant parameter. The server applies exactly the same rule: strip a trailing _app or _test, and when neither is present, fall back to the text before the last underscore. It also rejects a mismatching X-Tenant-Id with 403 TENANT_KEY_MISMATCH, so a derived tenant can never widen access. ContentFlowKeys.tenantFor is that server rule byte for byte, last-underscore fallback included, and its own documentation comment says it mirrors the server. ContentFlowError.InvalidConfiguration is therefore raised only where the server would also parse nothing out of the key, which is a key with no underscore at any position after its first character. Every key the server resolves, this SDK resolves too, including keys that resolve to a workspace id that does not exist: those are dialled, not refused. See the table below. If your key predates the format, pass the tenant explicitly:

legacy key
ContentFlow.start(context, apiKey = "legacy-key", tenantId = "ws_1a2b3c")
! A pk_live_… key is resolved and dialled by this SDK, not refusedOlder marketing copy and old snippets show keys of the pk_live_… generation. Neither _app nor _test terminates that string, so the server falls back to the text before the last underscore, and so does this SDK: pk_live_abc resolves to the workspace id pk_live. The call goes out, and the server answers 404 TENANT_NOT_FOUND naming pk_live. It does not fail at configuration time. An earlier version of this page said this SDK refused such a key with ContentFlowError.InvalidConfiguration and never made the call; that was wrong, and it described the Swift SDK, which is the strict one. Copy your real key from Dashboard, Developers, "Your keys" rather than adapting an old example.
! 401 INVALID_SDK_KEY means no workspace id could be parsed, not "no underscore"The resolver returns nothing only when the key has no underscore at any position after its first character, and that is the case the server answers 401 INVALID_SDK_KEY. Measured live: _app, _test, _ and app all return 401 INVALID_SDK_KEY, and _app plainly contains an underscore, while __app returns 404 Tenant not found: _ because _ parses as a workspace id and simply names no workspace. That pair is the whole distinction: 401 is "unreadable key", 404 is "readable key, no such workspace".

Where the server, this SDK and the Swift SDK disagree

Three resolvers, two of them identical. The server strips _app or _test, then falls back to the text before the last underscore. ContentFlowKeys.tenantFor is that function reproduced exactly. The Swift SDK strips _app or _test and has no fallback at all, so it refuses client side what this SDK sends.

Key you configureServer resolvesKotlin SDKSwift SDK
ws_abc_appws_abcws_abcws_abc
pk_live_abcpk_live, then 404 TENANT_NOT_FOUNDpk_live, then 404 TENANT_NOT_FOUNDRefused before the call
acme_applicationacme, then 404 TENANT_NOT_FOUNDacme, then 404 TENANT_NOT_FOUNDRefused before the call
abc_abc, then 404 TENANT_NOT_FOUNDabc, then 404 TENANT_NOT_FOUNDRefused before the call
_appNothing parses, 401 INVALID_SDK_KEYRefused before the callRefused before the call
abcNothing parses, 401 INVALID_SDK_KEYRefused before the callRefused before the call

If you ship both platforms, the three legacy-shaped rows, pk_live_abc, acme_application and abc_, are the ones to test. A legacy key produces a 404 from Android and a configuration failure on iOS, which look nothing alike in a crash report or a support ticket even though the cause is one string. Neither platform can reach a workspace its key does not name.

i What the Kotlin and Swift pages share, and the four places they differShared, and checkable against either page: both SDKs derive the workspace from the key, both send X-CF-Key, X-Tenant-Id and X-CF-Device on every /sdk/* call including the SSE stream even though the server requires only the key, both send ?locale= on GET reads and never on a POST, and neither ever sends X-API-Key. Different: (1) an unparseable key, per the table above, where this SDK dials what Swift refuses; (2) stream resume, the Last-Event-ID header here against a ?lastEventId= query parameter there; (3) stream query parameters, absent here and present there; (4) User-Agent, your HTTP client's own default here against contentflow-swift/1.0.0 there. See rest-api.html for the header contract both implement.

The environment comes from the suffix and nothing else

A key ending exactly in _test selects test and UAT; anything else is live. No request body ever carries an environment field, no header flips it, and the SDK never rewrites your key. Caches are namespaced by a hash of the full key, the tenant, the derived environment, the endpoint category, the locale and a cache schema version, so a test key and a live key can never observe each other's content on the same device.

Headers on every request

All three go on every call, the SSE stream included.

HeaderValue
X-CF-KeyYour publishable key, verbatim
X-Tenant-IdThe configured tenant, derived from the key unless you passed one
X-CF-DeviceThe SDK's stable device id

Accept-Language carries the active locale as a BCP 47 tag on every request. ?locale= goes on the GET reads only, /sdk/sync, /sdk/blocks/:key and /sdk/strings, and never on a POST. This SDK sets no User-Agent of its own, so whatever your OkHttpClient sends by default goes out; the Swift SDK sends contentflow-swift/1.0.0, so the two platforms are not separable the same way in an access log. Neither SDK ever sends X-API-Key: both use X-CF-Key exclusively, even though the server accepts either.

i The server does not require X-Tenant-Id, and both SDKs send it anywayThe key already names the workspace, so /sdk/* answers without the header. Sending it is a client decision, taken identically here and in the Swift SDK, and it has one consequence worth knowing: a tenant that disagrees with the key is answered 403 TENANT_KEY_MISMATCH rather than ignored. "Not required by the server" and "not sent by the SDK" are different claims, and only the first is true.

The device id is a random UUID generated once and stored in the SDK's own SharedPreferences file, committed synchronously so a process death cannot lose it. It is never an advertising id, never ANDROID_ID, and never any hardware identifier. Clearing app data rotates it, which is the correct behaviour.

! The SDK sends a device id. The server does not require oneAttaching all three headers is this SDK's own behaviour, not a platform requirement. The content reads (GET /sdk/sync, GET /sdk/blocks/:key, GET /sdk/strings) answer 200 with a workspace key alone, with no device id and no identify call, and on a live _app key they write nothing server side. A persisted UUID is still a persistent identifier you have to declare in a store listing, and sending traits on identify can pull an otherwise-offline app into declaring sensitive user data it never needed to collect. If all you want is remotely editable copy, call the REST reads directly and declare nothing. See Anonymous, content-only mode.
i X-API-Key is an accepted aliasSend X-API-Key instead of X-CF-Key on any /sdk/* call and the effect is identical: supported today, not deprecated. A ?key= query parameter is refused everywhere except the live-update stream route, GET /sdk/stream, where an EventSource-style client cannot set request headers; used on any other /sdk/* route it is refused with 401 SDK_KEY_IN_QUERY. This SDK never uses that carve-out: OkHttp sets headers on the SSE request where a browser EventSource cannot, so the stream URL carries no query parameters at all. The Swift SDK does use it, and puts ?device= and ?tenantId= in the stream URL as well.
! X-CF-Read-Key is a different credential, never ship it in this appThree server side read routes, GET /api/v1/read/users/{userId}/devices, GET /api/v1/read/devices/{deviceId}, and GET /api/v1/read/users/{userId}/segments, take a separate key shaped rk_live_ plus 24 lowercase hex characters. It is a server side and CI credential, never something this SDK sends or should hold. Full contract in rest-api.html.
! Never point the base URL at api.contentflow.clickThe only supported base is https://app.contentflow.click/api/v1, which is the SDK's DEFAULT_BASE_URL. The retired https://api.contentflow.click/v1 host was never switched off. It answers a legacy key with 200 and real-looking block data in the old unenveloped format, and that content is frozen: nothing you publish will ever reach it. It does not fail cleanly, which is precisely what makes it expensive. Nothing in this SDK sends a request there, and you should not override baseUrl to point at it. See how to tell which host you are on.

Concept

Placements and renderers

A placement is exactly a block type key

ContentFlowSlot(placement = "home.discovery") fetches /sdk/blocks/home.discovery. There is no placement resolution layer: no normalisation, no case folding, no prefixing, no fuzzy matching and no fallback, because the delivery API has no placement concept to resolve against. If a placement renders nothing, the first thing to check is that a block with that exact key is published to this workspace.

Registering a renderer is required

A block payload carries content: field tags, types and values. It carries no layout, no template identifier and no rendering instructions. A slot therefore cannot invent native UI, and any SDK that claims otherwise is guessing. You register a composable per placement, once, and the slot calls it.

renderer registration
ContentFlow.registerRenderer("home.discovery") { block ->
    DiscoveryCard(block)
}

In a release build an unregistered placement renders nothing and reports a renderer_missing diagnostic, once per placement rather than on every recomposition. In Compose previews, and when you opt in with ContentFlow.setDevelopmentFallback(true), an unregistered placement instead renders a plain diagnostic view of the payload so you can see delivery working before you write the real UI.

The inline form, when a registry is overkill

The second ContentFlowSlot overload takes the content lambda directly and bypasses the registry, with optional slots for the loading, empty and error states.

inline slot
ContentFlowSlot(
    placement = "home.discovery",
    loading = { ShimmerCard() },
    empty = { },
    error = { e -> Log.w("cf", e.message.orEmpty()) },
) { block ->
    DiscoveryCard(block)
}

Both overloads behave the same way underneath: cached content appears immediately, a transient failure never replaces content already on screen, Empty renders the empty slot, and every slot on the screen shares one observation and one SSE connection. A slot composed before ContentFlow.start renders nothing and reports a slot_before_start diagnostic instead of crashing.

i Impressions are automaticThe slot records one impression engagement event per block instance, attributed with the placement as blockKey and the block's screen. It is deduplicated to once per instance per session, and dropped entirely while analytics consent is denied.

Concept

Fields, tags and types

On the wire, a block's content is a tag-keyed values map joined against a fields schema array of {tag, type}. The SDK normalises that into a list of ContentField, addressed by tag.

block payload
{
  "key": "discovery_card",
  "instanceId": "inst_9f2c",
  "segment": "high_value",
  "version": 7,
  "values": { "#header_image": "https://…", "#title_main": "Hello" },
  "fields": [ { "tag": "#header_image", "type": "image" },
              { "tag": "#title_main",   "type": "text" } ]
}

Address fields by tag, never by index. Field order is preserved from the wire for debugging and generic rendering, but position is not a contract.

Tags are matched exactly

Matching is case-sensitive and literal. #title_main and title_main are different tags. If you look up the unprefixed form of a tag that exists with the #, the SDK still returns nothing, but it emits a tag_not_normalized diagnostic naming the tag it did find, so the mistake is visible. Duplicate tags in one scope resolve to the first occurrence and raise duplicate_tag.

Strict and lenient accessors

ContentBlock exposes the same three accessors.

AccessorTag absentType mismatch
field(tag): ContentField?Returns nullReturns the field, undecoded
value(key): T? (lenient)Returns nullReturns null plus a field_type_mismatch diagnostic
require(key): T (strict)Throws ContentFlowError.FieldMissingThrows ContentFlowError.UnsupportedFieldValue

Use value for anything the design can survive without, and require only for a field the screen genuinely cannot render without.

Three decoding rules worth knowing

  • Declared but never published reads as absent. The field stays in block.fields as ContentFieldValue.Absent so the declared shape is still visible, but lenient access returns null and require throws FieldMissing. It is not reported as a type mismatch, because nothing was mistyped.
  • A value with no schema entry still reaches you, with its type inferred from the payload and a field_without_schema diagnostic. Dropping it would silently delete live content because of a schema gap, which is the worse failure.
  • An unrecognised wire type is preserved, not fatal. It becomes ContentFieldValue.Unknown carrying the raw JsonValue, readable with FieldKeys.raw(tag), because a marketer can publish a new field type without an app release.

Field key factories

FactoryReadsNotes
FieldKeys.text(tag)StringAlso resolves a textarea value without warning
FieldKeys.textarea(tag)StringAlso resolves a text value without warning
FieldKeys.image(tag)ContentImageurl, optional alt, width, height; isEmpty when no usable reference was published
FieldKeys.number(tag)BigDecimalDecimal, never binary floating point, so money renders exactly as authored
FieldKeys.select(tag)String
FieldKeys.toggle(tag)Boolean
FieldKeys.collection(tag)List<ContentBlock>The cards delivered under a collection tag. ContentBlock.collection(tag) is the everyday way to read one, because it degrades to an empty list rather than a null. See Collections
FieldKeys.raw(tag)JsonValueEscape hatch for a field type this version does not model. A collection read through it yields an empty JsonValue.Arr, deliberately: use collection(tag)
i Seven field types, and only one of them holds a listText, Textarea, Image, Number, Select and Toggle are scalars. Collection holds an ordered list of whole ContentBlock values, inlined by the server under the field's tag. A card is a block, so it exposes exactly these same accessors. See Collections.
! version and the ETag are two different things that share a nameContentBlock.version is an Int: the per-instance revision counter, incremented when a marketer republishes that instance. The snapshot validator is a separate, opaque String of the form W/"sync-<tenant>-<hash>", which the SDK stores and echoes byte for byte and never parses. It surfaces as SyncResult.version and LocalizedStrings.version. Comparing one to the other is meaningless.

Concepts

Collections: a card is a block

! Everything in this section is unbuilt sourceCollections are implemented in this package's source and covered by CollectionDecoderTest.kt, but that test has never been executed and the module has never been compiled. There is no JVM, no Gradle and no Android SDK on the machine it was authored on, and no artifact anywhere. Every signature below was read out of the source, not out of a build. See Build status.

A field the dashboard declares as collection holds an ordered list of whole block instances, which the server inlines under the field's tag. Each card arrives with its own key, instanceId, version, segment, values and field schema, and is decoded by the same code that decodes a top-level block.

There is no card type, no item type and no sub-schema on the field. The only addition to the model is ContentFieldValue.Collection(cards) and the matching FieldType.Collection.

Reading one

signatures
// ContentBlock
public fun collection(tag: String): List<ContentBlock>

// FieldKeys, when you prefer a declared key
public fun collection(tag: String): FieldKey<List<ContentBlock>>

collection(tag) is always a list: never null, never a throw. An absent tag, a tag that is not a collection, a declared-but-unpublished field, and a malformed collection value all read as empty, so a wrapper delivered by a stale proxy cannot break a screen and no wrapper draws list chrome around zero cards. A type mismatch also raises a field_type_mismatch diagnostic naming the tag.

Tag matching is the same lookup every other accessor uses, which means it is exact: #slides and slides are different tags, and looking up the unprefixed form of a tag that exists with the # returns nothing plus a tag_not_normalized diagnostic. Positional reads are ordinary Kotlin, so getOrNull(2) is the bounds-safe way to reach the third card.

reading a card
val title = carousel
    .collection("#slides")
    .getOrNull(2)
    ?.value(FieldKeys.text("#title"))

A carousel, end to end

Register a renderer for the card key and one for the wrapper key. ContentFlowCard resolves each card through the registry entry for the card's own block key, so a wrapper's renderer never has to know how its children look.

ReadingCarousel.kt
object CardFields {
    val Title = FieldKeys.text("#title")
    val Image = FieldKeys.image("#image")
}

// A card is a block, so it gets an ordinary renderer.
ContentFlow.registerRenderer("reading_card") { card ->
    ReadingCard(
        title = card.value(CardFields.Title),
        image = card.value(CardFields.Image),
    )
}

ContentFlow.registerRenderer("reading_carousel") { carousel ->
    Column {
        carousel.value(FieldKeys.text("#heading"))?.let { Text(it) }
        LazyRow {
            contentFlowCards(
                cards = carousel.collection("#slides"),
                wrapperInstanceId = carousel.instanceId,
                collectionTag = "#slides",
            )
        }
    }
}

The two Compose helpers

MemberWhat it does
@Composable fun ContentFlowCard(card: ContentBlock, modifier: Modifier = Modifier, position: Int? = null, wrapperInstanceId: String? = null, collectionTag: String? = null, trackImpression: Boolean = true, fallback: @Composable (ContentBlock) -> Unit = {})Renders one card through the renderer registered for that card's own block key. Falls back to the development preview under LocalInspectionMode or when the development fallback is enabled, otherwise warns once per unregistered key and renders fallback.
fun LazyListScope.contentFlowCards(cards: List<ContentBlock>, wrapperInstanceId: String? = null, collectionTag: String? = null, content: @Composable (card: ContentBlock, position: Int) -> Unit = { … })Emits one lazy item per card, keyed by the card's own instanceId. The default content is ContentFlowCard with the position and placement threaded through. An empty list emits nothing: no chrome, no gap.

Keying on instanceId is the point of the helper. A card moved from position 2 to position 5 keeps its identity and its UI state; a replaced card gets a new one. Array position is never identity, so this never keys on the index.

i A duplicate instance id is dropped rather than crashing the listA lazy list throws when two items share a key. The server rejects duplicate references, but a fixture or a stale proxy is not the server, so the first occurrence wins and the rest are dropped with a collection_duplicate_instance diagnostic.

Analytics

ContentFlowCard emits one impression per card instance per session, attributed to the card's own instanceId, with position, wrapperInstanceId and collectionTag attached as dimensions. Pass trackImpression = false if the app tracks visibility itself. Nothing is emitted while analytics consent is denied.

Building an event by hand is the same idea: EngagementEvent.of(block, type, position, wrapperInstanceId, collectionTag, custom) puts the card's instanceId, blockKey and screen on the event and folds the placement into custom as dimensions. Placement is context, not ownership: a card shown in three carousels reports against its own instance in all three, and the metric owner is never rewritten to the wrapper.

Malformed cards degrade, they never fail the wrapper

  • A collection value that is not a list reads as empty, with a collection_value_not_a_list diagnostic.
  • Inside the list a card with no usable key, instanceId or values map is skipped with collection_card_skipped. Siblings survive in delivered order.
  • Nesting is capped at one level, matching the server: a collection inside a card reads as empty with collection_nesting_exceeded, so a hand-built or stale payload cannot drive unbounded recursion.
i allowedBlockKeys is not a client-side filterA delivered collection field carries allowedBlockKeys, minItems and maxItems. The decoder reads only tag and type and ignores the rest: those are authoring constraints the server validates writes against, and an unrecognised card key still decodes as an ordinary ContentBlock.

Status

Repeaters were withdrawn, and replaced by collections

! There is no RepeaterValue, no RepeaterItem, and no FieldKeys.repeaterThe repeater field type, its nested payload, and that API were withdrawn and never shipped. They do not exist in this package. The replacement is documented above: Collections.

The withdrawn design stored a list's items as nested values inside a single block instance. That made every item a value rather than an entity, so an individual card could not carry its own segment targeting, its own schedule, its own approval state, its own A/B variants, or its own analytics. ContentFlow is instance centric and all of that machinery already exists at the instance level, so a list built out of values sat outside it.

A collection is that list expressed as a wrapper holding an ordered set of real block instances, so every card inherits all of it. In Kotlin that means FieldType.Collection, ContentFieldValue.Collection(cards), ContentBlock.collection(tag), FieldKeys.collection(tag), and the two Compose helpers. Declare FieldKeys.collection(tag), not a repeater key, and expect whole blocks under the tag, never { id, values } items.

The platform-level explanation is at Blocks & fields, Collections.



Behaviours this SDK chose on purpose

Each of these is a decision with a cost. They are stated here so you can design around them rather than discover them.

BehaviourWhy
Impressions are deduplicated to one per instance per session.Matches the web SDK, and stops slot recomposition inflating counts.
The event queue is in memory only and never touches disk.Behavioural data for a device that may never consent does not belong on disk. Process death losing a handful of impressions is the smaller problem. The queue is bounded by both count (500) and age (24 hours), and drops oldest first on overflow.
Traits replace the server's stored trait map, they do not merge.Send the full set every time. An empty map is a no-op, on the wire and on the server: it leaves the stored traits exactly as they are rather than clearing them. The SDK holds the last set you sent and resends it whenever it has to re-identify for its own reasons.
Writes are never retried by the transport.The server does not yet deduplicate on the client-generated eventId the SDK already sends, so retrying after an ambiguous timeout could duplicate a write. The analytics queue re-enqueues under its own bounded policy instead. Idempotent reads do retry, up to three attempts with jittered backoff.
A failed refresh never clears good content.A network failure is not evidence of deletion. A successful full sync is authoritative, so blocks absent from it are treated as unpublished; a failed one is not.
Coroutine cancellation is never swallowed.CancellationException is always rethrown untouched so structured concurrency keeps working. ContentFlowError.Cancelled only covers a call aborted because the client was closed.

Caching, offline and live updates

  • Stale-while-revalidate by default. The disk snapshot is published before the first request goes out, so a cold start with no network still renders.
  • Conditional requests use the stored weak ETag byte for byte, W/ and quotes included. The SDK never parses or reconstructs it. A 304 keeps the cached body and refreshes only validation metadata; a 304 whose cached body has gone triggers one unconditional refetch and an etag_without_body warning.
  • A captive portal or malformed proxy response never overwrites the cache. A sync response that parses as JSON but carries no blocks array is refused, and the cached snapshot is kept, because treating it as an authoritative empty snapshot would unpublish everything on screen.
  • Cache entries are checksummed and swapped in atomically, so a process death during a write leaves either the old valid snapshot or the new one, never a half file. A tampered or schema-mismatched entry is discarded with a cache_discarded diagnostic.
  • Live updates use one SSE connection per client, never one per slot. It pauses when the app backgrounds, reconnects with jittered backoff capped at 60 seconds, revalidates after every reconnect because events may have been missed, and collapses bursts of invalidations into one sync. A 401 or 403 on the stream is terminal until the configuration changes, rather than an infinite reconnect loop.
  • The stream request carries headers and no query parameters. All three auth headers plus Accept-Language are set on it, and the URL is bare /sdk/stream. A resumed stream sends the last event id as the standard Last-Event-ID header. The Swift SDK resumes with a ?lastEventId= query parameter and also sends ?key=, ?device=, ?tenantId= and ?locale= on the stream URL, so a proxy rule, a log filter, or a WAF signature written for one platform does not cover the other.
  • Polling is the fallback, not the default. The periodic foreground sync only fires while the stream is not connected.

All of this is configurable through SyncPolicy, which is a plain data class on the configuration. The defaults are a 5 minute foreground revalidation window, a 15 minute poll interval, a 10 second request timeout, a 10 event flush batch and a 15 second flush interval.

custom configuration
ContentFlow.start(
    context,
    ContentFlowConfiguration(
        apiKey = "ws_1a2b3c_app",
        tenantId = "ws_1a2b3c",
        locale = Locale.forLanguageTag("ar"),
        syncPolicy = SyncPolicy(liveUpdates = false, pollIntervalMillis = 5 * 60_000L),
        diagnostics = DiagnosticsLevel.Verbose,
    ),
)

Switching locale with setLocale invalidates locale-specific observations and revalidates. Caches are keyed per locale, so switching back is instant.


Diagnostics

Nothing is logged by default beyond errors. Diagnostics are never thrown and never break content delivery; a sink that throws is ignored rather than taking the SDK down.

diagnostics
ContentFlowDiagnostics.setLevel(DiagnosticsLevel.Verbose)
ContentFlowDiagnostics.addSink { Log.d("ContentFlow", "${it.code}: ${it.message}") }

addSink returns an AutoCloseable that removes the sink again. Codes worth knowing:

CodeMeans
renderer_missingA slot resolved a block but no renderer is registered for that placement
tag_not_normalizedYou looked up title and the block has #title
duplicate_tagTwo fields in one scope carry the same tag; the first won
field_type_mismatchThe tag exists but its published type is not what the key expects
field_without_schemaA published value has no schema entry; its type was inferred
legacy_double_envelopeA write response arrived double-wrapped; the inner payload was decoded
etag_without_bodyThe server answered 304 but the cached body is gone; refetching
cache_discardedA cache entry failed its schema, key, tenant, environment or checksum check
analytics_dropped_no_consentEvents were refused locally because analytics consent is denied
collection_card_skippedOne card in a collection had no usable key, instance id or values map; its siblings still decoded
collection_value_not_a_listA collection tag carried something other than an array; it reads as empty
collection_nesting_exceededA collection appeared inside a card. Depth is capped at one, so it reads as empty
collection_duplicate_instanceThe same card instance id appeared twice in one collection; only the first is rendered, because list identity must be unique

API reference

Module: core

Plain Kotlin/JVM. No Android types. Package click.contentflow.

ContentFlowClient

The delivery client, an AutoCloseable interface. One client owns one configuration: a key, its derived environment, a tenant, a locale. All background work is a child of a single job that close() cancels, so a tenant or account switch is a clean teardown rather than a leak.

MemberNotes
val configuration: ContentFlowConfiguration
val environment: ContentFlowEnvironmentDerived from the key suffix; never configurable, never sent in a body
val deviceId: StringStable, SDK-generated, persisted
val updates: SharedFlow<ContentUpdate>Invalidations, completed syncs, observed block changes, failed refreshes
val consent: StateFlow<ConsentSnapshot>Defaults to denied everywhere
suspend fun start()Publishes the disk snapshot, then begins background work. Safe to call more than once
suspend fun identify(userId: String?, traits: Map<String, JsonValue> = emptyMap()): Identitynull userId identifies an anonymous device. Traits replace rather than merge; an empty map is a no-op that leaves stored traits unchanged
suspend fun registerPushToken(token: String, platform: PushPlatform = PushPlatform.Fcm)Operational registration; does not grant push consent
suspend fun setAnalyticsConsent(status: ConsentStatus)Revoking purges anything queued
suspend fun setChannelConsent(channel: ConsentChannel, status: ConsentStatus)Requires a prior identify(userId = ...)
fun consentSnapshot(): ConsentSnapshotNon-suspending read
suspend fun trackEngagement(event: EngagementEvent): TrackingDisposition
suspend fun trackEngagement(events: List<EngagementEvent>): TrackingDisposition
suspend fun trackIdentityEvent(event: IdentityEvent): TrackingDispositionDoes not link the user locally; identify is the one identity path
suspend fun trackSignUp(properties: Map<String, JsonValue> = emptyMap()): TrackingDispositionNeeds an identified user, or throws InvalidConfiguration
suspend fun trackSignIn(properties: Map<String, JsonValue> = emptyMap()): TrackingDispositionSame
suspend fun setLocale(locale: Locale)Invalidates locale-specific observations and revalidates
suspend fun sync(policy: RefreshPolicy = RefreshPolicy.Revalidate): SyncResultConcurrent calls collapse into one request
suspend fun fetchBlock(key: String, policy: CachePolicy = CachePolicy.StaleWhileRevalidate): ContentBlock?Returns null when the key is unknown or nothing targets this device: absence is an empty result, not a failure
suspend fun getStrings(locale: Locale? = null, policy: CachePolicy = CachePolicy.StaleWhileRevalidate): LocalizedStringsApproved translations, defaulting to the client's locale
fun observeBlock(key: String): StateFlow<ContentBlockState>The same flow is returned for the same key, so any number of slots share one observation
override fun close()Flushes pending analytics on a short-lived scope, then cancels all background work

Configuration

TypeShape
ContentFlowConfiguration(apiKey, tenantId, locale = Locale.getDefault(), baseUrl = DEFAULT_BASE_URL, syncPolicy = SyncPolicy.Default, diagnostics = DiagnosticsLevel.Errors), plus a derived environment. Blank key or tenant, or a non-absolute base URL, fail at construction
SyncPolicysyncOnStart, foregroundRevalidateAfterMillis, pollIntervalMillis, liveUpdates, invalidationDebounceMillis, requestTimeoutMillis, maxReadAttempts, eventBatchSize, eventFlushIntervalMillis, eventQueueMaxCount, eventQueueMaxAgeMillis; SyncPolicy.Default
ContentFlowEnvironmentLive, Test; each carries a wireName
CachePolicyStaleWhileRevalidate, NetworkFirst, CacheOnly
RefreshPolicyRevalidate, Force
DiagnosticsLevelNone, Errors, Warnings, Verbose
PushPlatformFcm, Apns, Hms, WebPush; each carries wireProvider and wirePlatform
ContentFlowKeysenvironmentFor(apiKey), tenantFor(apiKey): String?, fingerprint(apiKey)
DEFAULT_BASE_URL, SDK_VERSION"https://app.contentflow.click/api/v1", "1.0.0"
Locale.toContentFlowTag()Extension normalising a locale to the BCP 47 tag used by ?locale= and Accept-Language

Content model

TypeShape
ContentBlock(instanceId, version: Int, segment: String?, fields: List<ContentField>, key = "", name: String?, screen: String?) plus field(tag), value(key), require(key), collection(tag): List<ContentBlock>
ContentField(id, tag, value: ContentFieldValue)
ContentFieldValueSealed: Text, Textarea, Image, Number, Select, Toggle, Collection(cards: List<ContentBlock>), Unknown(type, value), Absent; all expose typeName
ContentImage(url, alt: String?, width: Int?, height: Int?) plus isEmpty
FieldTypeText, Textarea, Image, Number, Select, Toggle, Collection, Unknown; each carries its wireName, plus fromWire(raw)
FieldKey<T>, FieldKeystag, expectedType; factories listed above
JsonValueSealed: Null, Str, Num(BigDecimal), Bool, Arr, Obj; JsonValue.of(...) overloads and String/Boolean/Int/Long/Double.toJsonValue()

Results, events and state

TypeShape
Identity(deviceId, userId: String?, segments: List<String>, analyticsConsentGranted: Boolean)
SyncResult(blocks, version: String?, freshness, notModified: Boolean)
ContentFreshnessFresh, Revalidated, Stale
LocalizedStrings(locale, strings, smartKeys, version) plus get(key) and value(key, fallback = key)
ContentBlockStateSealed: Loading(cached), Available(block, freshness), Empty, Failed(error, cached); all expose blockOrNull
ContentUpdateSealed: Invalidated(event, payload), Synced(result), BlockChanged(key, state), RefreshFailed(error)
EngagementEvent(type, instanceId, blockKey, blockType, screen, position, custom, eventId = UUID, occurredAt = now). Requires an instanceId or a blockKey. Build one with EngagementEvent.of(block, type, position, wrapperInstanceId, collectionTag, custom), which attributes the event to the block itself and folds wrapperInstanceId / collectionTag into custom as dimensions
EngagementTypeImpression, Tap, CtaClick, Dismiss, Conversion
IdentityEvent(userId, name, fullName, email, phone, properties); constants SIGN_UP, SIGN_IN
TrackingDispositionSealed: Accepted, Queued, DroppedNoConsent. Never an error
ConsentStatusGranted, Denied; isGranted, of(granted)
ConsentChannelPush, Sms, Whatsapp, Email, Location, Marketing; each carries its wireName
ConsentSnapshot(analytics, channels) plus channel(channel)

Errors and diagnostics

ContentFlowError is a sealed Exception hierarchy: InvalidConfiguration, AuthenticationFailed(status), AuthorizationFailed(status), NotFound(resource), RateLimited(retryAfterMillis), ServerError(status), TransportError, Timeout, DecodingError, InvalidResponseEnvelope, CacheError, Cancelled, ConsentDenied, UnsupportedFieldValue(tag, expected, actual) and FieldMissing(tag).

ContentFlowDiagnostics is a process-wide fan-out with level(), setLevel(level), addSink(sink): AutoCloseable, clearSinks() and report(severity, code, message, cause). A Diagnostic carries (severity, code, message, cause), severity being Debug, Warning or Error. DiagnosticsSink is a fun interface, so a lambda works.

Platform and factory

TypeShape
ContentFlowPlatform(storageDirectory, deviceIdStore = FileDeviceIdStore(storageDirectory), foreground: StateFlow<Boolean>, deviceContext: Map<String, String>) plus deviceId; ContentFlowPlatform.jvm(directory)
DeviceIdStoreInterface: read(): String?, write(deviceId)
FileDeviceIdStoreFile-backed, used by the JVM and as the Android fallback
ContentFlowClientFactorycreate(configuration, platform, httpClient: OkHttpClient? = null, parentScope: CoroutineScope? = null, clock: () -> Long = System::currentTimeMillis)

The factory is the seam that makes core testable: pass a caller-owned scope and an OkHttp client pointed at a mock server and the whole client runs on the JVM.


API reference

Module: android

The ContentFlow object is the entry point. It holds at most one client, in an AtomicReference, and bootstraps on its own supervisor scope so a failed startup never takes down a host scope and nothing is ever launched on GlobalScope.

MemberNotes
val client: ContentFlowClientThrows IllegalStateException before start
val clientOrNull: ContentFlowClient?Null before start
fun start(apiKey: String, locale: Locale = Locale.getDefault()): ContentFlowClientTenant derived from the key, context captured at process start
fun start(context: Context, apiKey: String, locale: Locale = Locale.getDefault()): ContentFlowClientExplicit context, derived tenant
fun start(context: Context, apiKey: String, tenantId: String, locale: Locale = Locale.getDefault()): ContentFlowClientExplicit tenant, for a key that predates the format
fun start(context: Context, configuration: ContentFlowConfiguration): ContentFlowClientFull configuration
suspend fun stop()Closes the running client and cancels its background work

Every overload is @JvmStatic, and the ones with defaults are @JvmOverloads, so Java callers get the same shapes. Repeated start calls are deterministic: an identical configuration returns the existing client, and a different one closes the old client first, so switching tenant or environment tears down the old streams, timers and caches rather than stacking on top.

ContentFlowInitProvider is public only because the manifest names it. You never call it. The module also derives the device context sent once per analytics batch: platform, osVersion, deviceModel and appVersion. Foreground state comes from ProcessLifecycleOwner; if lifecycle-process is absent the SDK assumes foreground and warns rather than stalling.


API reference

Module: compose

Three extensions on ContentFlow, two ContentFlowSlot overloads, and the two collection helpers. The registry behind them is internal, keyed by placement, and warns once per unregistered placement.

MemberNotes
fun ContentFlow.registerRenderer(placement: String, renderer: @Composable (ContentBlock) -> Unit)A blank placement fails immediately. The same registry serves collection cards, keyed by the card's own block key
fun ContentFlow.setDevelopmentFallback(enabled: Boolean)Turns on the generic development renderer for unregistered placements. Development and previews only
fun ContentFlow.clearRenderers()Removes every registered renderer. Intended for tests
@Composable fun ContentFlowSlot(placement: String, modifier: Modifier = Modifier)Renders through the registry
@Composable fun ContentFlowSlot(placement: String, modifier: Modifier = Modifier, loading: @Composable () -> Unit = {}, empty: @Composable () -> Unit = {}, error: @Composable (ContentFlowError) -> Unit = {}, content: @Composable (ContentBlock) -> Unit)Inline content, bypassing the registry
@Composable fun ContentFlowCard(card: ContentBlock, modifier: Modifier = Modifier, position: Int? = null, wrapperInstanceId: String? = null, collectionTag: String? = null, trackImpression: Boolean = true, fallback: @Composable (ContentBlock) -> Unit = {})Renders one collection card through the renderer registered for its own block key, and emits its impression against the card's own instanceId
fun LazyListScope.contentFlowCards(cards: List<ContentBlock>, wrapperInstanceId: String? = null, collectionTag: String? = null, content: @Composable (card: ContentBlock, position: Int) -> Unit = { … })One lazy item per card, keyed by card.instanceId. Duplicate ids are dropped with a diagnostic rather than crashing the list

The observation is keyed on placement and client only, so a new lambda or modifier identity on recomposition never restarts it, and it is collected with collectAsStateWithLifecycle. In LocalInspectionMode, which is what a Compose preview runs in, an unregistered placement falls back to the development preview automatically so previews are not blank.


What is in v1, and what is not

This SDK is not at parity with the JavaScript SDK, and it does not pretend to be. Missing capabilities are omitted rather than stubbed, because a local-only stub would tell an app that something changed on the server when it did not.

StatusCapabilities
In v1start, identify, push token registration, analytics consent, per-channel consent, engagement tracking, identity events with sign_up and sign_in helpers, locale switching, full sync, single block fetch, translations with smart keys, live updates, block observation as a StateFlow, renderer registration and ContentFlowSlot
Deferred until a production wire contract existsPush topics and push preferences (the JavaScript SDK's updatePushTopics and getPushTopics), server-driven consent configuration and consent UI (its getConsentConfig and requestConsent). Those endpoints exist in some form, but their request shapes, error behaviour and semantics are not documented well enough to commit to a public Kotlin API
Not ported into the core SDKLocation permission and tracking, campaign evaluation, KYC and industry profile. These are either the application's responsibility or separate products, not content delivery primitives
Not ported at allVariant picking (targeting is server authoritative), CSS url(...) stripping (web-specific; the delivery API already normalises image values), string interpolation (no documented escaping or missing-variable behaviour yet), and the raw block wire parsers, which stay internal so malformed-payload behaviour does not become permanent public API

Known limitations, stated plainly

  • Engagement events are held in memory only and do not survive process death.
  • The server does not deduplicate on the client-generated eventId the SDK already sends, so an ambiguous timeout can duplicate an event. Writes are therefore never retried by the transport.
  • Traits replace rather than merge. Send the full set every time. An empty map, or omitting traits altogether, is a no-op that leaves stored traits unchanged; there is no way to clear a device's traits through this field.
  • There is no code generation. Typed field keys give you the safety without a schema download, a build plugin, or generated models that drift when a marketer edits a block.

Guide

Migrating off a hand-written REST client

If you already talk to /sdk/* from your own OkHttp or Retrofit code, most of what you built maps onto something here. The REST reference remains the authority on the wire itself; this SDK is a client for exactly that contract and adds no endpoints of its own.

What you hand-rolledWhat replaces it
Persisting a device id in SharedPreferences and sending X-CF-DeviceHandled. The id is generated once, committed synchronously, and sent on every call including the SSE stream
Setting X-CF-Key and X-Tenant-Id on every requestHandled from the configuration; the tenant is derived from the key
Unwrapping {"success": true, "data": …}, and the data.data double wrap on writesHandled, including one legacy nested layer with a legacy_double_envelope diagnostic. Deeper recursion is refused
Storing the ETag and sending If-None-MatchHandled per endpoint category and per locale, echoed byte for byte
Deciding what to do with a 304Handled: cached body kept, validation metadata refreshed, one unconditional refetch if the body has gone
Reading values by tag and switching on the type from fieldsFieldKeys plus block.value or block.require
Polling /sdk/sync on a timerOne SSE connection with foreground gating and jittered reconnect, with polling only as the fallback
Deciding whether to send consent: falseHandled by the three-state rule. Stop sending a reflexive false
Batching engagement eventstrackEngagement, with impression dedupe, bounded in-memory queue, and consent enforced before anything is stored

Two things worth checking during the swap. First, your base URL: if your old client ever pointed at api.contentflow.click it was reading frozen content, and moving to this SDK will change what your app displays. That is the fix landing, not a regression. Second, your version handling: if you were comparing a block's version against the sync ETag, they are different values with the same name, and only one of them is an integer.

✓ A sensible migration orderKeep your own client shipping. Consume this source in a branch, get :core:test green, then replace one screen's block fetching with a ContentFlowSlot and compare. Move the rest once a build has actually succeeded.
REST API reference Back to docs