ContentFlow Android SDK (Kotlin)

Version: 2.4.1

Native Kotlin delivery SDK for ContentFlow: dynamic blocks, translations, identity, consent and engagement analytics, with a Compose slot that renders a placement wherever you drop it.

What's New in 2.4.1

See the changelog.

What's New in 2.4.0

See the changelog.

What's New in 2.3.0

What's New in 2.2.2

What's New in 2.2.1

What's New in 2.2.0

What's New in 2.1.0

What's New in 2.0.1

What's New in 2.0.0

Do not point baseUrl at app.contentflow.click

That host is the ContentFlow portal. It still forwards SDK paths to the SDK API for older integrations, but SDK traffic belongs on api.contentflow.click, at https://api.contentflow.click/sdk/v1.

Configuring the base URL

Prefer configuration over code, so a future host or version change needs no code change. Precedence: an explicit baseUrl > platform config > the default https://api.contentflow.click/sdk/v1. Platform config, first match wins:

XML
<!-- AndroidManifest.xml, inside <application> -->
<meta-data android:name="click.contentflow.BASE_URL" android:value="https://api.contentflow.click/sdk/v1" />

On a plain JVM (the :core module), use the system property -Dcontentflow.baseUrl=…, then the environment variable CONTENTFLOW_BASE_URL.

A wrong base URL does not necessarily fail loudly with a 4xx/5xx: a stale or misconfigured host can answer 2xx with a body that carries no ContentFlow envelope. Because of that, this SDK raises ContentFlowError.InvalidBaseUrl on a successful response whose body is not a ContentFlow envelope, logs it at error level, and lets nothing from that response reach either the UI or the disk cache.


Quickstart

1. Depend on it

There is no published Maven coordinate yet. The SDK ships as source in a release archive, and your app builds it as a Gradle composite build.

  1. Download contentflow-sdk-kotlin-2.4.1.zip and the checksum file SHA256SUMS-2.4.1.txt (for example curl -fLO https://dev.contentflow.click/downloads/sdk/2.4.1/contentflow-sdk-kotlin-2.4.1.zip). Every version is listed on Releases and downloads.
  2. Verify and extract it into your app repository:
Terminal
mkdir -p vendor && cd vendor
shasum -a 256 contentflow-sdk-kotlin-2.4.1.zip   # Linux: sha256sum. Must equal this file's hash in SHA256SUMS-2.4.1.txt
unzip contentflow-sdk-kotlin-2.4.1.zip           # creates vendor/contentflow-sdk-kotlin/
  1. Include it:
Kotlin
// settings.gradle.kts (your app)
includeBuild("vendor/contentflow-sdk-kotlin")
Kotlin
// app/build.gradle.kts
dependencies {
    implementation("click.contentflow:compose")    // resolved by includeBuild, no version
    // implementation("click.contentflow:android") // instead of :compose if you don't use Compose
    // implementation("click.contentflow:location") // optional: device location
    // implementation("click.contentflow:geofencing") // optional: on-device geofences (Play services)
}

The composite build compiles the SDK with your app, so it needs JDK 17 and your Android SDK. If you are not using Compose, depend on android instead and read content through ContentFlowClient (see Without Compose).

Or project includes, if you vendor the modules into your own build:

Kotlin
// settings.gradle.kts
include(":contentflow-sdk-kotlin:core")
include(":contentflow-sdk-kotlin:android")
include(":contentflow-sdk-kotlin:compose")
include(":contentflow-sdk-kotlin:location")   // optional
include(":contentflow-sdk-kotlin:geofencing") // optional
Kotlin
// app/build.gradle.kts
dependencies {
    implementation(project(":contentflow-sdk-kotlin:compose"))
}

Upgrading: replace vendor/contentflow-sdk-kotlin with the new verified archive, read the Action needed items in the changelog for every version you skip (2.2.0 added interface members and TrackingDisposition.Rejected; 2.2.1 changed the default base URL; 2.4.0 added interface members and ContentFlowEnvironment.Unknown), run ./gradlew --refresh-dependencies build, and repeat Testing your integration.

2. Register what a placement looks like, then start

Kotlin
import click.contentflow.ContentFlow
import click.contentflow.ContentFlowSlot
import click.contentflow.registerRenderer
import java.util.Locale

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

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

        // "<workspace-id>_test" while developing; "<workspace-id>_app" only in release builds.
        // Keys: dashboard, Developers → Your keys (Environment toggle).
        ContentFlow.start(this, apiKey = "<workspace-id>_test", locale = Locale.getDefault())
    }
}

Every ContentFlowClient call is a suspend function: call it from a coroutine, for example lifecycleScope.launch { ... }.

Kotlin
lifecycleScope.launch {
    val client = ContentFlow.client
    // After login. start() already registered the device anonymously.
    val identity = client.identify(
        userId = "<your-user-id>",
        traits = mapOf("tier" to JsonValue.Str("gold")),   // replaces the stored set; send all traits
    )
    Log.d("ContentFlow", "segments=${identity.segments}")

    // From your consent UI, with the user's actual choices.
    client.setAnalyticsConsent(ConsentStatus.of(userAcceptedAnalytics))
    client.setChannelConsent(ConsentChannel.Push, ConsentStatus.of(userAcceptedPush)) // a grant needs the identify above
}

4. Drop the slot into your UI

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

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.

5. Read fields by tag, with types

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

@Composable
fun DiscoveryCard(block: ContentBlock) {
    val title = block.value(DiscoveryFields.Title)          // null if absent
    val hero = block.require(DiscoveryFields.Hero)          // throws if absent or wrong type
}

Tags are matched exactly and are case-sensitive. #title_main and title_main are different tags; the SDK will tell you through a diagnostic if you look up the unprefixed form of a tag that exists with the #.

Three decoding rules are worth knowing:

6. Lists: a card is a block

A collection field holds an ordered list of whole block instances, delivered inline. There is no card type, no item type and no sub-schema: collection(tag) hands you ordinary ContentBlocks, decoded by the same code that decoded the wrapper.

Kotlin
ContentFlow.registerRenderer("reading_carousel") { carousel ->
    LazyRow {
        contentFlowCards(
            cards = carousel.collection("#slides"),   // always a list, never null
            wrapperInstanceId = carousel.instanceId,
            collectionTag = "#slides",
        )
    }
}

// A card renders through the registry under its own block key, so the carousel
// never has to know what a reading card looks like.
ContentFlow.registerRenderer("reading_card") { card ->
    ReadingCard(
        title = card.value(FieldKeys.text("#title")),
        image = card.value(FieldKeys.image("#image")),
    )
}

What that buys you, and what it costs:


What a placement is

A placement is exactly a block type key. ContentFlowSlot(placement = "home.discovery") fetches /sdk/blocks/home.discovery. There is no placement resolution layer, no normalization, no prefixing and no fallback matching, 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.

Why you register a renderer

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 claiming otherwise is guessing. You register a composable per placement, once, and the slot calls it.

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

Environment and tenant

The environment is derived from the key suffix and nothing else: a key ending in _test is test/UAT, anything else is live. No request body ever contains an environment field, and the SDK never rewrites your key.

The tenant is derived from the key too, using the format the server itself enforces (<tenantId>_app or <tenantId>_test). The SDK API takes the workspace from the key alone and ignores X-Tenant-Id, so a tenant value can never widen what a key can reach. If your key predates that format, pass the tenant explicitly:

Kotlin
ContentFlow.start(context, apiKey = "<legacy-key>", tenantId = "<workspace-id>")

Saudi PDPL compliance is not a feature flag here.

Kotlin
// Inside a coroutine, with the user's actual choices
ContentFlow.client.setAnalyticsConsent(ConsentStatus.of(userAcceptedAnalytics))
ContentFlow.client.setChannelConsent(ConsentChannel.Marketing, ConsentStatus.of(userAcceptedMarketing))

Channel consent is stored against a user profile, so it requires a prior identify(userId = ...) and fails fast with a clear error otherwise.

The device id is a random UUID generated once and persisted in the SDK's own preferences. It is never an advertising id, an ANDROID_ID, or any hardware identifier. Clearing app data rotates it, which is the correct behaviour.

Offline, caching and live updates

Show in-app messages

In-app campaign sends and journey in-app steps arrive on sync() as blocks with the key journey_message (or the placement you chose for the campaign or journey), one per message (instanceId jm_<id>; the jm_ prefix, not the key, is what marks a message), newest first, up to 20. Each carries #title, #body, and, when set, #image and a #cta_label / #cta_url button (web links only). They show on the next sync: at start, on returning to the foreground, or when you call sync(). Live updates don't announce them, and polling runs only while the live stream is down, so call sync() when the user opens the screen that shows them. observeBlock("journey_message") holds only the newest one still pending; read the list instead:

Kotlin
val messages = ContentFlow.client.sync().blocks.filter { it.key == "journey_message" }
for (message in messages) {
    val title = message.value(FieldKeys.text("#title")).orEmpty()
    val body = message.value(FieldKeys.text("#body")).orEmpty()
    val imageUrl = message.value(FieldKeys.image("#image"))?.url
    val label = message.value(FieldKeys.text("#cta_label"))
    // #cta_url is typed "url", which this SDK keeps as ContentFieldValue.Unknown.
    val link = ((message.field("#cta_url")?.value as? ContentFieldValue.Unknown)?.value as? JsonValue.Str)?.value
    show(message, title, body, imageUrl, label, link)
    ContentFlow.client.track(CFEngagementEvent.of(message, EngagementType.Impression))   // once, when on screen
}

suspend fun onButton(message: ContentBlock) {
    ContentFlow.client.trackCtaClick(message)    // counts the click once and stops later syncs delivering it
}

suspend fun onClose(message: ContentBlock) {
    ContentFlow.client.trackDismiss(message)                                      // stops later syncs delivering it
}

Tracking dismiss, cta_click or tap on a message closes it on this device straight away, with or without analytics consent: the SDK remembers its instance id (nothing else, for up to 8 days) and leaves it out of sync(), fetchBlock, observeBlock and ContentFlowSlot from then on, including after a restart. logout(), a different user identifying, deleteIdentity() and resetFrequency() forget these dismissals. The event itself is still sent only with analytics consent; once it reaches the server, the message stops being delivered at all. To react to every sync, collect ContentUpdate.Synced from ContentFlow.client.updates. Details: SDK guide: Show in-app messages.

Workspace feature switches

ContentFlow can switch some built-in SDK features off for a workspace. The switches arrive in the sync response you already make (config.features), so there is no extra request, and the client honours them:

Switch When off
live_updates No live-updates stream is opened (an open one is closed); content refreshes by normal ETag polling. When it turns back on, the stream starts again, but only if SyncPolicy.liveUpdates is on.
campaigns getCampaigns(...) returns an empty list without a network request.
location Location tracking is paused and points not yet sent are dropped (the server would drop them too). Manual location batches are not sent. When it turns back on, tracking resumes only if your app started it and has not stopped it since.
frequency_caps The server applies no popup frequency rule. The SDK ignores its local popup frequency ledger: shouldShow is always true and getPopup offers the top item (from 2.4.0).
content_sync The server keeps the content you already have (a 304), or answers an empty list on a first install, so your built-in defaults show. Nothing changes in the SDK.
strings The server keeps the strings you already have, or answers an empty catalog, so your bundled strings show.
analytics_events Events are dropped on the device, never sent: track(...) returns TrackingDisposition.Rejected with a reason starting feature_disabled, and string-sensor signals are discarded. Identity events are dropped by the server.
push_preferences Topic and preference changes are accepted but not stored. Push token registration keeps working.
geofences The server sends no fences to monitor.
profile_updates The device is still registered and erasure is still enforced, but nothing is written to the customer's profile; the device keeps its last known segments.

A missing or unreadable switch counts as on, a 304 keeps the last value, and the last value is kept with the cached sync response. A switch can only turn a feature off: it never starts anything your app did not start, never grants consent (location still needs analytics consent), and never changes the base URL, keys, authentication or certificate pinning.

Kotlin
val features: SdkFeatures = client.features   // liveUpdates, campaigns, location, frequencyCaps
client.featuresFlow.collect { println("Feature switches: $it") }

What the SDK covers

On the client (ContentFlow.client / ContentFlowClient): start, identify, analytics and per-channel consent, push token registration and removal (unregisterPushToken), logout, GDPR erasure (deleteIdentity), campaigns (getCampaigns), unified track() with trackEngagement/trackIdentity/ trackIndustry, the category helpers (commerce, engagement, identity, growth, account, system, location), locale switching, full sync, single block fetch, translations with smart keys, live updates, block observation as a StateFlow, collections of whole blocks, renderer registration, ContentFlowSlot, ContentFlowCard and contentFlowCards.

On client.extensions (ContentFlowClientExtensions): locales catalog, string sensors, push topics and preferences, consent presets and the full consent flow, visitor id, IP geolocation, location batches and geofence events, a client-side LocationManager, domain-profile updates, and the write-key registration calls (registerBlock, syncBlocks, registerStrings, disableStrings).

Not available yet (blocked on the backend): submitKYC and getKYCState exist in the API but the public SDK API has no route for them, so they fail with NotFound.

Deprecated: getIndustryProfile always returns null without a request. Industry data is write-only from the SDK (updateIndustryProfile); ContentFlow uses it server-side, and there is no read-back route, by design.

Not ported at all: pickVariant (targeting is server authoritative), stripCssUrl (web-specific; the delivery API already normalizes image values), interpolate (no documented escaping or missing-variable behaviour yet), and the raw parseBlock/parseBlocks wire parsers, which stay internal so malformed-payload behaviour does not become permanent public API.

Known v1 limitations, stated plainly:

Diagnostics

Nothing is logged by default beyond errors. Set the level through the configuration or with ContentFlowDiagnostics.setLevel(...) (see below), and attach a sink to see what the SDK is doing:

Kotlin
ContentFlowDiagnostics.addSink { Log.d("ContentFlow", "${it.severity} ${it.code}: ${it.message}") }
ContentFlow.start(
    applicationContext,
    ContentFlowConfiguration(
        apiKey = "<workspace-id>_test",
        tenantId = "<workspace-id>",
        diagnostics = DiagnosticsLevel.Verbose,   // debug builds only
    ),
)

A level set with setLevel wins, before or after start(), and every later start keeps it. Until you call it, each start applies the configuration's diagnostics level (default Errors). ContentFlowDiagnostics.resetLevel() hands control back to the configuration.

Codes worth knowing: invalid_base_url (always error level, see the base URL warning at the top), renderer_missing, duplicate_tag, field_type_mismatch, legacy_double_envelope, etag_without_body, cache_discarded, events_dropped_no_consent, request_signing_unsupported, invalid_sdk_key, queue_dropped, and for collections collection_card_skipped, collection_value_not_a_list, collection_nesting_exceeded, collection_duplicate_instance.

Error handling: a rejected key, retries and queue caps

A rejected SDK key stops the client. The key is treated as dead when the server answers:

Any other 403 (DEVICE_NOT_IDENTIFIED, USER_MISMATCH, identity or location refusals, a 403 with no code) is a refusal of that one call and stays a ContentFlowError.AuthorizationFailed. Write-key routes (registerStrings, disableStrings, syncBlocks) use a different credential and never kill the SDK key.

Once the key is dead, the client:

Kotlin
ContentFlowDiagnostics.addSink { d ->
    val dead = d.cause as? ContentFlowError.InvalidKey ?: return@addSink
    Log.e("ContentFlow", "SDK key rejected (${dead.code}), dropped ${dead.droppedCount}")
}

To resume, start a new client with a valid key or configuration (ContentFlow.start(context, newConfiguration) closes the old one), or restart the app. The state is in memory only and never persisted.

Retries. Only a transport failure (no response), a 429 or a 5xx is retried, with exponential backoff and jitter (1 s base, doubling, capped at 5 minutes). A Retry-After header (seconds or an HTTP date, honoured up to 1 hour) is the minimum wait. Any other 4xx is not retried: the request fails, and a queued batch that gets one is dropped. Reads retry up to SyncPolicy.maxReadAttempts times; writes are never retried by the transport.

Queue caps. Every retry queue (string signals, device location points) holds at most 500 items. Past that the oldest are dropped, and the drops are reported as a queue_dropped warning at most once a minute, carrying the count since the previous report.

Without Compose, strings and push

Kotlin
// Content as a StateFlow (Views / ViewModel)
lifecycleScope.launch {
    ContentFlow.client.observeBlock("home.discovery").collect { state ->
        when (state) {
            is ContentBlockState.Available -> bind(state.block)
            is ContentBlockState.Loading -> state.cached?.let(::bind)
            is ContentBlockState.Failed -> state.cached?.let(::bind)
            ContentBlockState.Empty -> hideSlot()
        }
    }
}

// Translations
lifecycleScope.launch {
    ContentFlow.client.setLocale(Locale("ar"))
    val strings = ContentFlow.client.getStrings()
    titleView.text = strings.value("home.title", fallback = "Welcome")
}

// Push: in your FirebaseMessagingService. Registering a token is not push consent.
override fun onNewToken(token: String) {
    CoroutineScope(Dispatchers.IO).launch {
        ContentFlow.clientOrNull?.registerPushToken(token, PushPlatform.Fcm)
    }
}

Testing your integration

  1. In the dashboard, copy the test key <workspace-id>_test (Developers → Your keys) and publish the block you are testing to Test. A test key sees live content plus content published to Test.
  2. Confirm the server has content for that key: curl -H 'X-CF-Key: <workspace-id>_test' https://api.contentflow.click/sdk/v1/sync (an empty blocks array means nothing is published for this key yet).
  3. Turn on diagnostics with diagnostics = DiagnosticsLevel.Verbose (see Diagnostics).
  4. Smoke test:
Kotlin
lifecycleScope.launch {
    val c = ContentFlow.client
    Log.d("CF", "env=${c.environment}")                              // Test
    val sync = c.sync()
    Log.d("CF", "blocks=${sync.blocks.map { it.key }}")
    Log.d("CF", "block=${c.fetchBlock("home.discovery")}")            // null = not published to this key/env
    c.setAnalyticsConsent(ConsentStatus.Granted)                      // test build only
    Log.d("CF", "event=${c.trackEngagement(type = "tap", blockKey = "home.discovery")}") // Accepted
}
Symptom Cause and fix
The slot renders nothing Wrong key or environment, the block is not published to this environment, or no renderer is registered (renderer_missing diagnostic)
DroppedNoConsent Call setAnalyticsConsent(ConsentStatus.Granted) from the user's choice
Rejected(reason) The server refused the event or the request failed; read reason
InvalidBaseUrl baseUrl is not a ContentFlow API; remove it or use https://api.contentflow.click/sdk/v1
InvalidConfiguration from setChannelConsent A grant needs identify(userId = ...) first (a revoke before identify is kept and sent by the next identify)
IllegalStateException from ContentFlow.client ContentFlow.start(...) has not run; start it in Application.onCreate
Dashboard impression counters do not move Events sent with a _test key are accepted but never change the production block counters

Building and testing the SDK

This section is about building the SDK itself, not testing your app.

The "Kotlin SDK" job in .github/workflows/sdk-release-artifacts.yml builds and tests this package on every pull request that touches it, with JDK 17, Gradle 8.9 and Android platforms;android-34:

Terminal
gradle --no-daemon --max-workers=2 test assemble

That compiles :core, :android and :compose, runs the :core unit tests, and assembles the debug and release AARs for :android and :compose. Only :core has tests: :android and :compose are compiled and packaged but have no unit or instrumented tests yet.

There is no committed Gradle wrapper, so use a local Gradle 8.9 (or run gradle wrapper once). :core needs a JDK 17 toolchain and no Android SDK; the other two modules need an Android SDK (ANDROID_HOME or local.properties).

Terminal
gradle :core:test       # contract tests, JVM only, no emulator
gradle test assemble    # all three modules, needs the Android SDK

:core is plain Kotlin/JVM on purpose, so its tests need no emulator: envelope decoding, wrong base URL detection, ETag and 304 handling, collection decoding, typed field access, consent gating and cache namespacing, driven against an OkHttp MockWebServer.

Not covered by CI: publishing (no maven-publish yet), device behaviour (lifecycle and connectivity callbacks, push delivery), and the extended REST calls in ContentFlowClientExtensions, which compile but have no tests against the live SDK API.

Requirements


Offline Resilience & Security

A full configuration is passed to ContentFlow.start(context, configuration). tenantId is required here (the part of the key before _test/_app):

Kotlin
val config = ContentFlowConfiguration(
    apiKey = "<workspace-id>_test",
    tenantId = "<workspace-id>",
    diagnostics = DiagnosticsLevel.Verbose,
    security = SecurityConfig(rateLimitPerSecond = 10, rateLimitMaxQueue = 50),
)
ContentFlow.start(applicationContext, config)

Offline events

Events are not persisted or retried. With analytics consent granted, track() sends immediately and returns Accepted. If the request fails it returns Rejected(reason) and the event is lost unless your app retries it. OfflineConfig is deprecated and has no effect. String signals and device location points do retry, from in-memory queues capped at 500 items; see Error handling above.

Request signing (not supported)

SecurityConfig.enableRequestSigning and signingSecretKey are deprecated and have no effect: requests are never signed. No ContentFlow server verifies a request signature, and a secret shipped in an APK is readable by anyone who unpacks it. Turning signing on reports one request_signing_unsupported error diagnostic per client. Remove both options. Requests are authenticated by the publishable key, which the server binds to exactly one workspace.

Secure storage

SecurityConfig.enableSecureStorage is deprecated and has no effect: the SDK does not use encrypted storage. The device id is a random UUID in the SDK's private SharedPreferences (never an advertising or hardware id), and the user id is held in memory. The consent state, segment names and content cache are files in the app's private storage, unencrypted. None of them is a credential.

Rate Limiting

Coroutine-based token bucket prevents accidental request spam: SecurityConfig(rateLimitPerSecond = 10, rateLimitMaxQueue = 50) (the defaults), as in the configuration above.

Certificate Pinning

Public-key (SPKI) pinning through OkHttp's CertificatePinner. Off by default.

Kotlin
val config = ContentFlowConfiguration(
    apiKey = "<workspace-id>_app",
    tenantId = "<workspace-id>",
    security = SecurityConfig(
        enableCertificatePinning = true,
        pinnedCertificates = listOf(
            "sha256/<current server key>",
            "sha256/<backup key>",
        ),
    ),
)

How it behaves:

Getting pins. For the server's current key:

Terminal
openssl s_client -connect api.contentflow.click:443 -servername api.contentflow.click </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout \
  | openssl pkey -pubin -outform der \
  | openssl dgst -sha256 -binary | base64

Prefix the output with sha256/. Current pin of api.contentflow.click (certificate valid until 2026-12-11): sha256/uYvQE/yGyakA32XBHLaqAjnLJHS82yNLfoh4CRV6lX8=. For the backup, pin a key that is not deployed yet but that you hold (a pre-generated spare server key), or the issuing intermediate CA's key (run the same pipeline on the intermediate certificate). Never ship a single pin.

Rotation: add the new key's pin to a release before the server switches to that key, wait until old app versions have aged out, then drop the old pin. A pin mismatch fails every request with SSLPeerUnverifiedException, and only an app update can fix it. Pinning also breaks behind TLS-intercepting corporate proxies.


Account lifecycle, campaigns and push removal

Kotlin
val client = ContentFlow.client

// Campaigns the server evaluates for a placement, highest priority first
val campaigns = client.getCampaigns("home.hero")
campaigns.firstOrNull()?.let { showBanner(it.title, it.imageUrl, it.actionUrl) }

// Sign-out: detaches the user and removes this device's push token server-side
client.logout()

// Stop push to this installation (idempotent; channel consent is unchanged)
client.unregisterPushToken()

// GDPR erasure of this device and its linked data; throws if not confirmed
client.deleteIdentity()

New in 2.4.0. Server-side caps also apply to older SDK versions. Full guide: Popup frequency and limits.

A block or in-app campaign can carry a frequency rule set in the portal: at most N impressions or taps per hour, day, week, month or ever, at most N per session, a minimum gap between shows, or "until the customer taps, closes or converts". The server already withholds an item that is capped. The SDK keeps a local ledger with the same rule, so a cap also holds before an event reaches the server, offline, from cache and within a session.

Kotlin
// At most one modal/popup/interstitial for this screen: interruptive blocks
// from the last sync and in-app campaigns for the placement "home", highest
// priority first, after their rules and the workspace popup limits.
when (val popup = client.getPopup("home")) {
    is Popup.Block -> {
        showModal(popup.block)
        client.trackEngagement(CFEngagementEvent.IMPRESSION, popup.block.key, popup.block.instanceId)
        // when the user closes it:
        client.trackEngagement(CFEngagementEvent.DISMISS, popup.block.key, popup.block.instanceId)
    }
    is Popup.Campaign -> {
        showModal(popup.campaign)
        client.trackCampaign(popup.campaign, EngagementType.Impression)
        // when the user closes it:
        client.trackCampaign(popup.campaign, EngagementType.Dismiss)
    }
    null -> Unit
}

// Any block or campaign you render yourself:
if (client.shouldShow(block)) render(block)
client.frequencyReason(block)   // e.g. SuppressionReason.CappedImpressions, or null
client.resetFrequency()         // debug builds: show a "once ever" popup again

SDK-drawn campaign popup

New in 2.4.0.

An in-app campaign chosen by getPopup(screen) has a fixed schema (title, body, image, CTA label, action URL), so the SDK can draw it for you. A block popup is your own component when you set onBlockPopup; without it, a block that uses the standard fields is drawn in the same card (see Block popups below). getPopup and the frequency ledger above still decide what is shown; the popup only presents it and tracks it.

The built-in popup UI is Compose-only (:compose module). A View-based app has no built-in popup (presentPopup returns NoUiContext): attach your own CampaignPopupPresenter with client.popups.attach(...), or call presentPopup / getPopup, draw the result yourself and report with the tracking helpers below. Add the host once per screen (or once at the app root, passing the current screen):

Kotlin
// Optional. autoPresent is OFF by default: without it nothing is presented automatically.
ContentFlow.client.popups.config = PopupConfig(
    autoPresent = true,          // present on every screenChanged; default false
    delayMs = 500,               // 0..60000, default 0
    theme = PopupTheme(accentColor = 0xFF0B6E4F, cornerRadiusDp = 20f), // null colors follow light/dark
    onBlockPopup = { block -> showMyModal(block) },  // optional: you draw (and track) block popups
    onAction = { campaign, url -> router.handle(url) }, // true = handled; false/absent = SDK opens it
    onBlockAction = { block, url -> router.handle(url) }, // the same for a block drawn in the card
    blockPopups = BlockPopupMode.BuiltIn,            // default; Off = never draw blocks (2.4.0)
)

@Composable
fun HomeScreen() {
    ContentFlowPopupHost(ContentFlow.client, screen = "home")
    // ...
}

// Or on demand, without autoPresent:
val result = client.presentPopup("home") // PopupPresentation(shown, kind, id, reason)

Block popups (new in 2.4.1). When the chosen popup is a block and you set no onBlockPopup, the SDK draws it in the same card if it has a non-empty #title or #body. It reads #title, #body, #image (image field or URL text, https only), #cta_label and #cta_url; #cta_visible = false hides the button, and a missing image or CTA is left out. The result is kind = Block with the instance id. Tracking is the same exactly-once impression + cta_click or dismiss, sent as block events (key + instance id), so the block's frequency rule, campaign statistics and the dismissed in-app message state all apply. The CTA goes to onBlockAction(block, url) (see Block CTA links below). Right-to-left follows the client locale. onBlockPopup always wins; a block without those fields is still NotHandled; PopupConfig(blockPopups = BlockPopupMode.Off) restores the 2.4.0 behaviour. renderCampaignPopup applies to campaigns only. A custom CampaignPopupPresenter draws blocks by overriding presentBlock / dismissBlock (a BlockPopupRequest has the same title, body, imageUrl, ctaLabel and callbacks as a campaign request); CFBlockPopup(request) is the Compose card.

Block CTA links (from 2.4.1). A block drawn in the built-in card follows the campaign link rules: only http, https and app custom schemes are passed on, and the cta_click is always tracked. The handler that gets the link differs per platform: JavaScript onAction(item, url) (item is the block), Flutter onAction(item, url) (item is a CFBlockActionContext, item.block), Kotlin and Swift onBlockAction(block, url) (onAction stays campaign-only there). Return true when handled; otherwise the SDK does what it does for a campaign. One extra rule on Kotlin and Swift: if you set onAction but not onBlockAction, a block link with an app custom scheme (myapp://...) is not opened by the system, because your app routes its own scheme; http and https links still open. Set onBlockAction to route block deep links.

Tracking helpers for popups you draw yourself (same event names and fields as the JS SDK):

Kotlin
client.trackCampaignImpression(campaign)
client.trackCampaignClick(campaign)    // cta_click
client.trackCampaignDismiss(campaign)  // dismiss
client.trackCtaClick(block)            // block cta_click
client.trackDismiss(block)             // block dismiss

Extended features (client.extensions)

The write-key calls (registerBlock, syncBlocks, registerStrings, disableStrings) are for servers and CI only: never ship a write key in an app. On Android the SDK logs a one-time warning if they are called from an app.

Known issue (2.4.1 and earlier): don't use registerBlock or syncBlocks. They send each definition nested under definition, which the server does not read, and registerBlock sends a one-entry manifest to /cards/sync, which archives every other active block type in the workspace. Register block types with POST https://api.contentflow.click/sdk/v1/cards/register from CI instead (see the SDK guide).

Kotlin
val ext = ContentFlow.client.extensions

val locales = ext.getAvailableLocales()
ext.updatePushTopics(PushTopicUpdate(subscribe = listOf("offers")))
val topics = ext.getPushTopics()
val preset = ext.getConsentConfig("banking_ksa")
val geo = ext.getIPGeolocation()

// String sensors: only keys the server marked as sensors, only with analytics consent
ext.fetchStrings()
ext.trackStringImpression("home.title", screen = "home")

// Location (POST /sdk/location); requires the device to be identified with consent
ext.submitLocationBatch(listOf(LocationUpdate(lat = 24.71, lng = 46.67, accuracy = 12.0)))

// Domain profile for the signed-in user (stored under domainProfile.<industry>)
ext.updateIndustryProfile(IndustryProfileUpdate(IndustryType.Banking, mapOf("tier" to JsonValue.Str("gold"))))

KYC (submitKYC, getKYCState) is not available yet: the public SDK API has no route for it. getIndustryProfile is deprecated and always returns null: industry data is write-only from the SDK.

Strings as code (write key, server/CI only)

Kotlin
val writeKey = System.getenv("CF_WRITE_KEY")

// Create or update keys and their source text (POST /api/v1/strings/sync)
ext.registerStrings(
    listOf(StringRegistration(key = "home.title", defaultValue = "Welcome", context = "Home header")),
    writeKey = writeKey,
) // StringRegistrationResult(registered, updated, skipped)

// Stop tracking keys; their text is still served (POST /api/v1/strings/disable)
val result = ext.disableStrings(listOf("legacy.promo_title", "legacy.promo_body"), writeKey = writeKey)
// result.disabled, result.alreadyDisabled, result.unknown

disableStrings turns the keys' sensors off, so the SDK stops sending string events for them (they drop out of smartKeys on the next strings refresh) and the server rejects any that still arrive; apps keep receiving their text. It takes 1 to 500 non-empty keys of at most 200 characters; duplicates are dropped, and anything else throws IllegalArgumentException before a request is sent. Request failures (an invalid write key, a 400) throw ContentFlowError, like registerStrings. Tracking is turned back on in the portal (Sensors → Enable sensors); there is no SDK call for it, and registerStrings never changes whether a key is tracked. Both calls need the write key: run them from a server or CI job, never from a shipped app.


Device location (opt-in :location module)

Location lives in its own module, so apps that don't use it get no location code and no location permissions. The module's manifest adds ACCESS_COARSE_LOCATION and ACCESS_FINE_LOCATION (Android 12+ needs both, and the user may grant either); drop FINE with tools:node="remove" if you only want approximate location. No background-location permission is declared: tracking runs while the app is in use. It uses the platform LocationManager, so no Google Play Services.

Kotlin
// app/build.gradle.kts
implementation("click.contentflow:location")   // alongside :android or :compose (composite build)

The SDK never asks for permission. Request it from your Activity, after explaining why, then enable the provider:

Kotlin
import click.contentflow.location.enableDeviceLocation

val askLocation = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
    lifecycleScope.launch { ContentFlow.client.enableDeviceLocation(applicationContext) }
}
askLocation.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
Kotlin
val client = ContentFlow.client

when (client.locationPermission()) {
    LocationPermission.Precise, LocationPermission.Approximate -> Unit
    else -> return            // Denied (Android can't tell "never asked" apart), or Unavailable
}

val here = client.requestLocation()                       // one fix; nothing is sent

client.registerGeofence(GeofenceDefinition.Circle("store-12", 24.7136, 46.6753, radiusMeters = 150.0), onDwell = true)
client.registerGeofence(GeofenceDefinition.Polygon("district", listOf(24.70 to 46.60, 24.70 to 46.70, 24.80 to 46.70)))

client.startLocationTracking(LocationTrackingConfig(distanceFilterMeters = 50.0, batchSize = 10, minAccuracyMeters = 100.0))
// ...
client.stopLocationTracking()                             // sends what is batched

Tracking drops mocked fixes, fixes less accurate than minAccuracyMeters, and fixes that moved less than distanceFilterMeters, batches the rest and sends them to POST /sdk/location. Enter, exit and dwell are computed on the device.

App-defined fences (registerGeofence) are evaluated on the device only. The server now confirms device-reported events against the workspace's own fences, so events for a fence id the dashboard doesn't have no longer reach analytics. Define fences in the dashboard and use on-device geofences (below) instead. With approximate permission only, fixes are too coarse for a small minAccuracyMeters; the SDK logs a warning instead of silently dropping them.

Privacy (same rules as the Swift and JS SDKs):

Custom sources: implement LocationProviding and pass it to client.setLocationProvider(...).

On-device geofences (opt-in :geofencing module)

The OS watches the workspace's geofences and wakes the app on enter, exit and dwell, so triggers arrive in seconds without continuous GPS. The SDK fetches the fences from GET /sdk/geofences (nearest first, revalidated by ETag), registers the nearest ones with Google Play services' GeofencingClient, and reports each transition to POST /sdk/location with source: "device". The server deduplicates device and server evaluation, so a stay fires once.

It lives in its own module because it needs Google Play services (play-services-location) and background location; :location stays free of both.

Kotlin
// app/build.gradle.kts
implementation("click.contentflow:geofencing")   // alongside :android or :compose (composite build)

Permissions. The module's manifest declares ACCESS_FINE_LOCATION and ACCESS_COARSE_LOCATION (neither needs a Play Console declaration) and registers the SDK's ContentFlowGeofenceReceiver (not exported). It does not declare ACCESS_BACKGROUND_LOCATION: a library that declared it would force Google Play's background-location declaration on every app that depends on it. Android 10+ refuses to add geofences without background location, so your app must:

  1. declare it in its own manifest:

    XML
    <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
  2. request it at runtime on Android 10+, after fine location and in a separate step ("Allow all the time" in Settings on Android 11+);

  3. complete Google Play's background location declaration in the Play Console.

The SDK never asks for permission. Until fine and (on Android 10+) background location are granted, geofencing stays idle and retries on the next start or foreground.

Kotlin
import click.contentflow.geofencing.startGeofencing

// Call on every launch, e.g. right after ContentFlow.start(...) in Application.onCreate:
// Android may start the process only to deliver a transition.
lifecycleScope.launch {
    ContentFlow.client.startGeofencing(applicationContext)
    // Leave slots for regions your app registers itself:
    // ContentFlow.client.startGeofencing(applicationContext, GeofencingOptions(reservedAppRegions = 10))
}

// Later, to stop and unregister every SDK region. Pass the context so it also
// works in a fresh process that never started geofencing (it still removes the
// SDK's geofences an earlier launch left in Play services):
import click.contentflow.geofencing.stopGeofencing
ContentFlow.client.stopGeofencing(applicationContext)

Opt-in and consent. Nothing runs until the app calls startGeofencing. It then runs only while analytics consent is granted, the workspace location switch is on, and location consent has been explicitly granted with setChannelConsent(ConsentChannel.Location, ConsentStatus.Granted) (in this launch or an earlier one). Never asked counts as not granted, so geofencing stays idle until the grant (#1030). A grant takes effect once the server confirms it; a revoke (setChannelConsent(ConsentChannel.Location, ConsentStatus.Denied)) is enforced on the device immediately, even if the write fails, and calling startGeofencing again does not undo it. When any of these turns off, every SDK region is unregistered and pending events are dropped; when they turn back on, geofencing resumes, until stopGeofencing(). The server is the backstop: GET /sdk/geofences returns no fences when the user has no location consent, and that empty list unregisters everything. (The /location route's LOCATION_CONSENT_REQUIRED refusal is keyed on analytics consent, not location consent; it also stops geofencing until consent changes.) The JS and Swift SDKs apply the same rule. deleteIdentity() stops geofencing; logout() drops events not yet sent.

Limits and behaviour.

Custom monitors (tests, other platforms): implement GeofenceMonitor and pass it to client.startGeofencing(monitor, options).

Event Tracking (Unified Model)

Kotlin
val client = ContentFlow.client

// Engagement: from a delivered block (attributes the event to its instance)
client.track(CFEngagementEvent.of(block, EngagementType.Impression))
client.trackEngagement(type = "cta_click", blockKey = "home_hero", custom = mapOf("destination" to JsonValue.Str("promo_page")))

// Identity (sent to POST /sdk/track-event). The username must be the user
// this device identified as.
client.trackIdentity(CFIdentityEvent.SIGN_UP, username = "user-123", email = "user@example.com")

// Industry
client.trackIndustry(
    type = "transfer_completed",
    industry = IndustryType.Banking,
    data = mapOf("transactionId" to JsonValue.Str("TXN-123"), "amount" to JsonValue.Num(500), "currency" to JsonValue.Str("SAR")),
)

Every call returns a TrackingDisposition: Accepted, DroppedNoConsent (analytics consent is denied; nothing was sent), or Rejected(reason) (the server refused it or the request failed; it was not buffered).


Industry-Specific Tracking

Industry events are CFIndustryEvents on the unified track() path. Typed builders exist per industry (for example CFIndustryEvent.banking(...)), or use the wrapper with a free-form type:

Kotlin
ContentFlow.client.trackIndustry(
    type = "claim_submitted",
    industry = IndustryType.Insurance,
    data = mapOf("claimId" to JsonValue.Str("CLM-456"), "amount" to JsonValue.Num(15000)),
)

Configuration Reference

Option Type Default Description
apiKey String required Publishable key: <workspace-id>_test (test) or <workspace-id>_app (live)
tenantId String required (derived for you by ContentFlow.start(context, apiKey)) <workspace-id>
baseUrl String https://api.contentflow.click/sdk/v1 SDK API base; see Configuring the base URL
locale java.util.Locale Locale.getDefault() Content locale
diagnostics DiagnosticsLevel Errors None, Errors, Warnings or Verbose; applied on start unless the app called ContentFlowDiagnostics.setLevel
syncPolicy SyncPolicy SyncPolicy.Default Refresh and live-update behaviour
offline.* OfflineConfig Deprecated, no effect: there is no offline event queue
security.enableRequestSigning Boolean false Deprecated, no effect: requests are never signed
security.enableSecureStorage Boolean true Deprecated, no effect: no encrypted storage is used
security.enableCertificatePinning Boolean false SPKI pinning of the baseUrl host
security.pinnedCertificates List [] sha256/<base64> public-key pins, at least 2
security.rateLimitPerSecond Int 10 Token bucket rate
security.rateLimitMaxQueue Int 50 Max requests waiting for a token

Generated from the ContentFlow repository by sdk-release/scripts/render-docs.mjs. Downloads and checksums are on Releases and downloads. The SDKs are licensed under the ContentFlow SDK License.