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.
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.Build status
What is written, what is unverified, and what to expect on the first build.
Quickstart
Start, register a renderer, place a slot, read fields by tag.
API reference
The public surface of all three modules, grouped by module.
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.
| Thing | State |
|---|---|
| Public API, three modules, wire encoding and decoding | Written, 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 build | Never 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 |
| Publishing | Not on Maven Central and not on any other repository. No maven-publish configuration exists in the build at all |
| Version | 1.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.
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.
includeBuild("../contentflow-sdk-kotlin")
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.
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.
$ 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
:core:test target is the cheapest place to start because it needs neither the Android SDK nor a device.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.
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
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.
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
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.
| Module | Kind | Android namespace | Contains |
|---|---|---|---|
core | Plain Kotlin/JVM | not an Android library | ContentFlowClient, configuration, content model, typed field keys, errors, diagnostics, the whole transport and cache |
android | Android library (AAR) | click.contentflow.android | The ContentFlow facade, ContentFlowInitProvider, lifecycle and preferences bindings |
compose | Android library (AAR) | click.contentflow.compose | ContentFlowSlot, 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
| Requirement | Value |
|---|---|
| minSdk | 24, on both android and compose |
| compileSdk | 34 |
| Java / JVM target | 17 (jvmToolchain(17) on all three modules) |
| Kotlin | 2.0.21 |
| Android Gradle Plugin | 8.5.2 |
| Gradle distribution | 8.9, per gradle-wrapper.properties |
| Coroutines | 1.9.0 |
| kotlinx.serialization JSON | 1.7.3 |
| OkHttp | 4.12.0 |
| Compose BOM | 2024.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.
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.
| Key | Derived tenant | Environment |
|---|---|---|
ws_1a2b3c_app | ws_1a2b3c | live |
ws_1a2b3c_test | ws_1a2b3c | test |
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:
ContentFlow.start(context, apiKey = "legacy-key", tenantId = "ws_1a2b3c")
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 configure | Server resolves | Kotlin SDK | Swift SDK |
|---|---|---|---|
ws_abc_app | ws_abc | ws_abc | ws_abc |
pk_live_abc | pk_live, then 404 TENANT_NOT_FOUND | pk_live, then 404 TENANT_NOT_FOUND | Refused before the call |
acme_application | acme, then 404 TENANT_NOT_FOUND | acme, then 404 TENANT_NOT_FOUND | Refused before the call |
abc_ | abc, then 404 TENANT_NOT_FOUND | abc, then 404 TENANT_NOT_FOUND | Refused before the call |
_app | Nothing parses, 401 INVALID_SDK_KEY | Refused before the call | Refused before the call |
abc | Nothing parses, 401 INVALID_SDK_KEY | Refused before the call | Refused 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.
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.
| Header | Value |
|---|---|
X-CF-Key | Your publishable key, verbatim |
X-Tenant-Id | The configured tenant, derived from the key unless you passed one |
X-CF-Device | The 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.
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.
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.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.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.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.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.
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.
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.
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.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.
{
"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.
| Accessor | Tag absent | Type mismatch |
|---|---|---|
field(tag): ContentField? | Returns null | Returns the field, undecoded |
value(key): T? (lenient) | Returns null | Returns null plus a field_type_mismatch diagnostic |
require(key): T (strict) | Throws ContentFlowError.FieldMissing | Throws 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.fieldsasContentFieldValue.Absentso the declared shape is still visible, but lenient access returnsnullandrequirethrowsFieldMissing. 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_schemadiagnostic. 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.Unknowncarrying the rawJsonValue, readable withFieldKeys.raw(tag), because a marketer can publish a new field type without an app release.
Field key factories
| Factory | Reads | Notes |
|---|---|---|
FieldKeys.text(tag) | String | Also resolves a textarea value without warning |
FieldKeys.textarea(tag) | String | Also resolves a text value without warning |
FieldKeys.image(tag) | ContentImage | url, optional alt, width, height; isEmpty when no usable reference was published |
FieldKeys.number(tag) | BigDecimal | Decimal, 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) | JsonValue | Escape hatch for a field type this version does not model. A collection read through it yields an empty JsonValue.Arr, deliberately: use collection(tag) |
Text, 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.Collections: a card is a block
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
// 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.
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.
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
| Member | What 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.
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_listdiagnostic. - Inside the list a card with no usable
key,instanceIdor values map is skipped withcollection_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.
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.Repeaters were withdrawn, and replaced by collections
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.
Consent
Saudi PDPL compliance is not a feature flag here, and this section is worth reading in full before you wire a consent screen to it.
Everything starts denied
On a fresh install, analytics consent and every channel consent are ConsentStatus.Denied. Consent is never inferred: not from calling identify, not from registering a push token, not from an OS notification permission, not from a server segment, and not from a previous installation. Only an explicit application call changes it. A corrupt consent file resets to denied, failing closed rather than open.
Content still syncs while consent is denied
This is deliberate. Blocks, strings and locales are delivered normally to an unconsented device; only analytics is dropped. Your app is not broken for a user who declined measurement.
The three-state wire contract on identify
identify has no consent parameter. It sends the SDK's own persisted decision, and omits the field entirely until the application has made one. The server reads the three cases differently:
consent on the wire | Server behaviour | When the SDK sends it |
|---|---|---|
| Field absent | Leave the stored decision unchanged | The app has never called setAnalyticsConsent on this install |
true or false | Record that decision | The app made an explicit grant or an explicit denial |
| Present but not a boolean | 400, rejected outright | Never. It is a client bug, not "leave unchanged" |
The reason for the absent case is concrete: a reflexive false on every launch would silently revoke a grant the same user made on web or another surface. An explicit denial does send false, because saying no is a real decision that has to reach the server. Identify can never grant consent, and the local store distinguishes "never asked" from "said no" even though both refuse analytics locally.
Setting consent
ContentFlow.client.setAnalyticsConsent(ConsentStatus.Granted) ContentFlow.client.setChannelConsent(ConsentChannel.Marketing, ConsentStatus.Granted)
setAnalyticsConsent persists the decision, purges the queue first on any non-grant so a queued event cannot escape between the local change and the round trip, and then re-identifies to propagate the decision. Because the server replaces the stored trait map rather than merging into it, that internal re-identify resends the last traits the app supplied.
setChannelConsent covers Push, Sms, Whatsapp, Email, Location (wire name locationTracking) and Marketing. Channel consent is stored against a user profile, so it requires a prior identify(userId = ...) and fails fast with ContentFlowError.InvalidConfiguration otherwise. Every channel is sent on each call, so local and server state cannot drift apart.
What denial actually does locally
- Engagement and identity events are refused before anything is stored, so a denied device holds no analytics data even in memory. Calls return
TrackingDisposition.DroppedNoConsent, which is a result, not an error. - Revoking consent purges anything queued, immediately, and a batch that fails while consent is revoked mid-flight is dropped rather than re-queued.
- Registering a push token is an operational registration, not consent. It does not grant push consent.
Observe consent reactively through client.consent, a StateFlow<ConsentSnapshot>, or read it once with client.consentSnapshot().
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.
| Behaviour | Why |
|---|---|
| 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. A304keeps the cached body and refreshes only validation metadata; a304whose cached body has gone triggers one unconditional refetch and anetag_without_bodywarning. - A captive portal or malformed proxy response never overwrites the cache. A sync response that parses as JSON but carries no
blocksarray 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_discardeddiagnostic. - 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
401or403on 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-Languageare set on it, and the URL is bare/sdk/stream. A resumed stream sends the last event id as the standardLast-Event-IDheader. 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.
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.
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:
| Code | Means |
|---|---|
renderer_missing | A slot resolved a block but no renderer is registered for that placement |
tag_not_normalized | You looked up title and the block has #title |
duplicate_tag | Two fields in one scope carry the same tag; the first won |
field_type_mismatch | The tag exists but its published type is not what the key expects |
field_without_schema | A published value has no schema entry; its type was inferred |
legacy_double_envelope | A write response arrived double-wrapped; the inner payload was decoded |
etag_without_body | The server answered 304 but the cached body is gone; refetching |
cache_discarded | A cache entry failed its schema, key, tenant, environment or checksum check |
analytics_dropped_no_consent | Events were refused locally because analytics consent is denied |
collection_card_skipped | One card in a collection had no usable key, instance id or values map; its siblings still decoded |
collection_value_not_a_list | A collection tag carried something other than an array; it reads as empty |
collection_nesting_exceeded | A collection appeared inside a card. Depth is capped at one, so it reads as empty |
collection_duplicate_instance | The same card instance id appeared twice in one collection; only the first is rendered, because list identity must be unique |
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.
| Member | Notes |
|---|---|
val configuration: ContentFlowConfiguration | |
val environment: ContentFlowEnvironment | Derived from the key suffix; never configurable, never sent in a body |
val deviceId: String | Stable, 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()): Identity | null 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(): ConsentSnapshot | Non-suspending read |
suspend fun trackEngagement(event: EngagementEvent): TrackingDisposition | |
suspend fun trackEngagement(events: List<EngagementEvent>): TrackingDisposition | |
suspend fun trackIdentityEvent(event: IdentityEvent): TrackingDisposition | Does not link the user locally; identify is the one identity path |
suspend fun trackSignUp(properties: Map<String, JsonValue> = emptyMap()): TrackingDisposition | Needs an identified user, or throws InvalidConfiguration |
suspend fun trackSignIn(properties: Map<String, JsonValue> = emptyMap()): TrackingDisposition | Same |
suspend fun setLocale(locale: Locale) | Invalidates locale-specific observations and revalidates |
suspend fun sync(policy: RefreshPolicy = RefreshPolicy.Revalidate): SyncResult | Concurrent 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): LocalizedStrings | Approved 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
| Type | Shape |
|---|---|
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 |
SyncPolicy | syncOnStart, foregroundRevalidateAfterMillis, pollIntervalMillis, liveUpdates, invalidationDebounceMillis, requestTimeoutMillis, maxReadAttempts, eventBatchSize, eventFlushIntervalMillis, eventQueueMaxCount, eventQueueMaxAgeMillis; SyncPolicy.Default |
ContentFlowEnvironment | Live, Test; each carries a wireName |
CachePolicy | StaleWhileRevalidate, NetworkFirst, CacheOnly |
RefreshPolicy | Revalidate, Force |
DiagnosticsLevel | None, Errors, Warnings, Verbose |
PushPlatform | Fcm, Apns, Hms, WebPush; each carries wireProvider and wirePlatform |
ContentFlowKeys | environmentFor(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
| Type | Shape |
|---|---|
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) |
ContentFieldValue | Sealed: 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 |
FieldType | Text, Textarea, Image, Number, Select, Toggle, Collection, Unknown; each carries its wireName, plus fromWire(raw) |
FieldKey<T>, FieldKeys | tag, expectedType; factories listed above |
JsonValue | Sealed: Null, Str, Num(BigDecimal), Bool, Arr, Obj; JsonValue.of(...) overloads and String/Boolean/Int/Long/Double.toJsonValue() |
Results, events and state
| Type | Shape |
|---|---|
Identity | (deviceId, userId: String?, segments: List<String>, analyticsConsentGranted: Boolean) |
SyncResult | (blocks, version: String?, freshness, notModified: Boolean) |
ContentFreshness | Fresh, Revalidated, Stale |
LocalizedStrings | (locale, strings, smartKeys, version) plus get(key) and value(key, fallback = key) |
ContentBlockState | Sealed: Loading(cached), Available(block, freshness), Empty, Failed(error, cached); all expose blockOrNull |
ContentUpdate | Sealed: 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 |
EngagementType | Impression, Tap, CtaClick, Dismiss, Conversion |
IdentityEvent | (userId, name, fullName, email, phone, properties); constants SIGN_UP, SIGN_IN |
TrackingDisposition | Sealed: Accepted, Queued, DroppedNoConsent. Never an error |
ConsentStatus | Granted, Denied; isGranted, of(granted) |
ConsentChannel | Push, 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
| Type | Shape |
|---|---|
ContentFlowPlatform | (storageDirectory, deviceIdStore = FileDeviceIdStore(storageDirectory), foreground: StateFlow<Boolean>, deviceContext: Map<String, String>) plus deviceId; ContentFlowPlatform.jvm(directory) |
DeviceIdStore | Interface: read(): String?, write(deviceId) |
FileDeviceIdStore | File-backed, used by the JVM and as the Android fallback |
ContentFlowClientFactory | create(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.
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.
| Member | Notes |
|---|---|
val client: ContentFlowClient | Throws IllegalStateException before start |
val clientOrNull: ContentFlowClient? | Null before start |
fun start(apiKey: String, locale: Locale = Locale.getDefault()): ContentFlowClient | Tenant derived from the key, context captured at process start |
fun start(context: Context, apiKey: String, locale: Locale = Locale.getDefault()): ContentFlowClient | Explicit context, derived tenant |
fun start(context: Context, apiKey: String, tenantId: String, locale: Locale = Locale.getDefault()): ContentFlowClient | Explicit tenant, for a key that predates the format |
fun start(context: Context, configuration: ContentFlowConfiguration): ContentFlowClient | Full 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.
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.
| Member | Notes |
|---|---|
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.
| Status | Capabilities |
|---|---|
| In v1 | start, 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 exists | Push 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 SDK | Location 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 all | Variant 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
eventIdthe 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
traitsaltogether, 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.
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-rolled | What replaces it |
|---|---|
Persisting a device id in SharedPreferences and sending X-CF-Device | Handled. 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 request | Handled from the configuration; the tenant is derived from the key |
Unwrapping {"success": true, "data": …}, and the data.data double wrap on writes | Handled, including one legacy nested layer with a legacy_double_envelope diagnostic. Deeper recursion is refused |
Storing the ETag and sending If-None-Match | Handled per endpoint category and per locale, echoed byte for byte |
Deciding what to do with a 304 | Handled: cached body kept, validation metadata refreshed, one unconditional refetch if the body has gone |
Reading values by tag and switching on the type from fields | FieldKeys plus block.value or block.require |
Polling /sdk/sync on a timer | One SSE connection with foreground gating and jittered reconnect, with polling only as the fallback |
Deciding whether to send consent: false | Handled by the three-state rule. Stop sending a reflexive false |
| Batching engagement events | trackEngagement, 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.
:core:test green, then replace one screen's block fetching with a ContentFlowSlot and compare. Move the rest once a build has actually succeeded.