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
- Action needed:
presentPopupandPopupConfig(autoPresent = true)now draw a block popup that uses the standard fields (a non-empty#titleor#body, like the Standard popup) in the built-in card, where 2.4.0 returnedNotHandled. SetPopupConfig(blockPopups = BlockPopupMode.Off)to keep the 2.4.0 behaviour. - Action needed: block links go to the new
PopupConfig.onBlockAction, notonAction. If you setonActionto route your own links, setonBlockActiontoo: without it a block link with an app custom scheme is not opened. - New:
PopupCardRequest/BlockPopupRequest,CampaignPopupPresenter.presentBlock, and the public ComposeCFBlockPopup. - Fixed: a dismissed in-app message no longer comes back on every sync when analytics consent is not granted.
See the changelog.
What's New in 2.4.0
- Action needed:
ContentFlowEnvironmenthas a new value,Unknown, reported byclient.environmentfor a created publishable key (<workspaceId>_cfpk..., previously shown asLive). An exhaustivewhenover the enum needs anUnknownbranch. - Action needed:
ContentFlowClienthas new abstract members (trackCampaign,frequencyReason,getPopup,resetFrequency); code that implements the interface itself (e.g. a test fake) must add them. - Action needed: on-device geofencing now stays idle until the app calls
setChannelConsent(ConsentChannel.Location, ConsentStatus.Granted); location consent that was never asked no longer counts as granted. - New: popup frequency control (#1049).
client.shouldShow(block | campaign),client.frequencyReason(...),client.getPopup(screen)(at most one interruptive block or in-app campaign per screen),client.resetFrequency(), andclient.trackCampaign(campaign, type)for campaign events. Tracked events update a local ledger, so caps hold offline and before events reach the server. See Popup frequency control and Popup frequency and limits. - New: SDK-drawn campaign popup.
ContentFlowPopupHost(client, screen)(Compose) draws in-app campaigns fromgetPopup;client.presentPopup(screen),client.screenChanged(screen)andclient.closePopup()(auto-present is opt-in). Tracking helperstrackCampaignImpression/trackCampaignClick/trackCampaignDismissand blocktrackCtaClick/trackDismiss. See SDK-drawn campaign popup. - Fixed: one session id per session, sent as
X-CF-Session-Idon sync, single-block and campaign reads and ascontext.sessionIdon event batches. A created test key is no longer treated as live. Channel consent grants are saved only once the server confirms them, and a revoke made beforeidentify()is sent by the nextidentify().getConsentConfig(preset)keeps the whole preset (#1082). - Changed: requests declare
X-CF-SDK-Capabilities: erasure-partial-v1,frequency-v1.
See the changelog.
What's New in 2.3.0
- Action needed: on-device geofencing (new
:geofencingmodule) needsACCESS_BACKGROUND_LOCATION, which the module does not declare. Declare it in your app's manifest, request it on Android 10+ and complete Play's background location declaration; without it geofencing stays idle. - Action needed: a rejected key (HTTP 401, or 403
INVALID_SDK_KEY/MISSING_SDK_KEY/SDK_KEY_IN_QUERY/TENANT_KEY_MISMATCH) now stops the client for that key: stream, periodic sync and location stop, queues are dropped, and later calls fail withContentFlowError.InvalidKeywithout a request. A 403TENANT_KEY_MISMATCHis nowInvalidKey(anAuthenticationFailed), no longerAuthorizationFailed. - Action needed: 2.3.0 is licensed under the proprietary ContentFlow SDK License (
LICENSE); earlier releases keep their terms. - New: on-device geofences (
:geofencing,client.startGeofencing(context)), workspace feature switches (client.features,client.featuresFlow),disableStrings(keys, writeKey)(server/CI only). - Fixed: retries cover only transport failures, 429 and 5xx, with backoff and
Retry-Afteras the minimum wait; retry queues keep at most 500 items; location is sent at most 100 points per request. - Changed: requests declare
X-CF-SDK-Capabilities: erasure-partial-v1, so a partial erasure answers 207 (deleteIdentity()throws and keeps the device id) instead of 502. - Deprecated:
getIndustryProfile()returnsnullwithout a request.
What's New in 2.2.2
Action needed: remove the deprecated no-op options below from your configuration.
Fixed:
ContentFlowDiagnostics.setLevel(...)is no longer overwritten bystart(). A level the app sets wins;resetLevel()hands control back to the configuration. The README named a diagnostics code that doesn't exist (analytics_dropped_no_consent); the real one isevents_dropped_no_consent.Deprecated, no effect: request signing (
enableRequestSigning,signingSecretKey), the offline event queue (OfflineConfig) and secure storage (enableSecureStorage). No server verifies a signature, and the queue and encrypted store were never used. Signing now reports arequest_signing_unsupportederror diagnostic instead of sending headers nothing checks. See Security and offline options.New: every request sends
X-CF-SDK-Version: kotlin/2.2.2and an emptyX-CF-SDK-Capabilities, so the server can tell SDK versions apart.
What's New in 2.2.1
- Changed: the default base URL is now
https://api.contentflow.click/sdk/v1(it washttps://api.contentflow.click/api/v1). The host is unchanged, so certificate pins are unaffected; request paths drop the doubled/sdkaccordingly. An explicit…/api/v1or…/api/v1/sdkbase still works but logs a one-time "legacy path" notice. - New: configure the base URL without code, with the manifest
<meta-data android:name="click.contentflow.BASE_URL">, the JVM system propertycontentflow.baseUrlor the environment variableCONTENTFLOW_BASE_URL(see Configuring the base URL).
What's New in 2.2.0
- Breaking / action needed:
ContentFlowClienthas new members (custom implementations must add them), andTrackingDispositionhas a newRejected(reason)case (exhaustivewhenneeds a branch). ContentFlowClient.extensionsexposes the extended API (locales, string sensors, push topics/preferences, consent presets, IP geolocation, location, domain profile, write-key registration).logout(),unregisterPushToken(),deleteIdentity()(GDPR erasure) andgetCampaigns().- Correct routes for identity events, string sensors, location and IP geolocation; failed events return
TrackingDisposition.Rejected. - Logging out, erasing, or a different user identifying clears the previous user's cached content.
- Device location capture in a new opt-in
:locationmodule: one-shot fixes, continuous tracking, circle/polygon geofences. - See CHANGELOG.md.
What's New in 2.1.0
- Builds and is tested in CI for the first time.
:core,:androidand:composecompile, and the:coresuite runs on every PR. - Breaking:
ContentFlow.offlineQueueandContentFlow.secureStorageare now internal. They never compiled before, so no app could have depended on them. - Fixed URLs:
registerStringsnow posts to{baseUrl}/strings/sync, andsyncBlocks/registerBlockto{baseUrl}/sdk/cards/sync. Both take a write key (X-CF-Write-Key), so they are server-side tools: never ship a write key in an app. - Certificate pinning (off by default): pins the host of the configured
baseUrlby public key (sha256/<base64>), for every client, including JVM clients fromContentFlowClientFactoryand a caller-suppliedhttpClient. At least 2 pins are required. Invalid setups throw at configuration time instead of silently disabling pinning. Hex certificate fingerprints are rejected.CFCertificatePinneris no longer public. CommerceEvent.booking(...)now defaultsstatustoCompleted(same value on the wire).SDK_VERSIONnow tracks the release (it said1.0.0).- Pinning covers only the
baseUrlhost: renew server certificates with the same key (e.g.certbot --reuse-key) or ship a backup pin first.
What's New in 2.0.1
- Fix: Resolved
PushPlatformredeclaration error (Configuration.kt vs Push.kt) - Fix: Added Number constructor to
JsonValue.Numwith NaN/Infinity validation - Fix: Renamed event-specific
actionproperties to avoid conflict withCFEventV2.action - Fix: Added missing
kotlinx.coroutines.launchimport in extensions
What's New in 2.0.0
9 Universal Event Categories: commerce, engagement, identity, growth, account, system, location, content, consent
IP Geolocation:
getIPGeolocation(),enrichWithIPGeolocation()Location Batch: GPS + geofence events via
submitLocationBatch()Full Localization:
setLocaleCode(),getString(),getAvailableLocales(), string sensorsBase URL:
https://api.contentflow.click/sdk/v1(the default from 2.2.1). The legacyhttps://api.contentflow.click/api/v1base (the default in 2.2.0 and earlier) still works and reaches the same SDK API, but logs a one-time "legacy path" notice.Package namespace:
click.contentflowModules:
core(pure Kotlin/JVM),android(AAR),compose(AAR),location(AAR, opt-in)
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:
<!-- 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.
- Validation: a configured value must be
https;httpis accepted only forlocalhost,127.0.0.1and10.0.2.2(the emulator). Any other value is rejected with abase_url_config_rejectederror diagnostic, and the default is used. An explicitbaseUrlkeeps its existing check (it must start withhttp). - Never remote: the base URL is never fetched remotely, because a remote value could redirect your app's traffic and keys and would bypass pinning.
- Legacy notice: a base that doesn't end in
/sdk/v1reports this once per client, as the error diagnosticbase_url_legacy(never blocking):ContentFlow: baseUrl <value> uses a legacy path. Update it to https://api.contentflow.click/sdk/v1 (see the 2.2.1 release notes).
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.
- Download
contentflow-sdk-kotlin-2.4.1.zipand the checksum fileSHA256SUMS-2.4.1.txt(for examplecurl -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. - Verify and extract it into your app repository:
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/- Include it:
// settings.gradle.kts (your app)
includeBuild("vendor/contentflow-sdk-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:
// 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// 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
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())
}
}3. Identify the user and record consent
Every ContentFlowClient call is a suspend function: call it from a
coroutine, for example lifecycleScope.launch { ... }.
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
@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
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:
- A field the schema declares but the payload never publishes reads as absent:
lenient access returns null and
requirethrowsFieldMissing. It is not reported as a type mismatch, because nothing was mistyped. - A value published with no schema entry still reaches you, with its type inferred from the payload. Dropping it would silently delete live content because of a schema gap.
block.versionis the per-instance revision counter, anInt. It is not the sync ETag, which is the stringW/"sync-<tenant>-<hash>"and is never parsed.
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.
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:
- Identity is
card.instanceId.contentFlowCardskeys every lazy item on it, so a card moved from position 2 to position 5 keeps its UI state and a replaced card gets a fresh one. Array position is never identity. If you write theLazyRowyourself, key oncard.instanceIdtoo. - Events belong to the card.
CFEngagementEvent.of(card, ...)attributes to the card's own instance id; the wrapper instance id, the collection tag and the position ride along as dimensions. The metric owner is never rewritten to the wrapper, so a card reused in three carousels reports as one card, with a placement breakdown available. - A card carries its own targeting, schedule, approval state and A/B variants, because it is a real instance. The server intersects it with the wrapper's, so a card can narrow the wrapper's audience but never widen it.
- Nothing throws. An absent tag, a tag that is not a collection, and a
malformed value all read as an empty list. Inside a collection, a card with no
usable
key, no usableinstanceIdor novaluesmap is skipped, its siblings are unaffected, and the survivors keep their delivered order. - Nesting stops at one. A collection inside a card is rejected server side, and reads as empty here, so a stale or hand-built payload cannot recurse.
- The wrapper is omitted server side when every card filters out, so an empty collection normally means a payload built by hand or served by a proxy. Render no chrome for it either way.
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:
ContentFlow.start(context, apiKey = "<legacy-key>", tenantId = "<workspace-id>")Consent, and why it defaults to denied
Saudi PDPL compliance is not a feature flag here.
- Every consent, analytics and per channel, starts denied on a fresh install.
identifyhas noconsentparameter. It sends whatever the app explicitly set, and omits the field entirely when the app has never made an explicit decision. The server reads an absentconsentas "leave the existing state alone", so a reflexivefalseon every launch would silently revoke a grant the same user made on web or another surface. An explicit denial does sendfalse, because saying no is a real decision that has to reach the server. Identify can never grant consent.- Registering a push token is an operational registration. It does not grant push consent.
- While analytics consent is denied, engagement and identity events are refused
locally and never stored, not even in memory. Calls return
TrackingDisposition.DroppedNoConsent, which is a result, not an error. - Revoking consent purges anything queued, immediately.
- Content delivery works fully with consent denied. That is deliberate: the server drops analytics, not content.
// 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
- Stale-while-revalidate by default: the disk snapshot is published before the first request goes out, so a cold start with no network still renders.
- Conditional requests use the stored weak ETag byte for byte,
W/and quotes included. The SDK never parses or reconstructs it. - A 304 keeps the cached body and refreshes only validation metadata. A 304 whose body is missing triggers one unconditional refetch.
- A failed refresh never clears good content, and a captive portal or malformed proxy response never overwrites the cache.
- A successful full sync is authoritative: blocks absent from it are treated as unpublished. A network failure is not evidence of deletion.
- Cache namespaces include a hash of the full API key, the tenant, the derived environment, the endpoint category, the locale and a cache schema version. A test key and a live key can never see each other's content.
- 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, and revalidates after every reconnect because events may have been missed. Bursts of invalidations collapse into one sync.
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:
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.
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:
- Engagement events are held in memory only and do not survive process death. This is deliberate: behavioural data for a device that may never consent does not belong on disk. The queue is bounded by both count and age.
- 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. - Impressions are deduplicated once per instance per session, matching the web SDK, so slot recomposition does not inflate counts.
- Traits replace the server's stored trait map rather than merging into it,
so send the full set every time. The one exception: an empty map means
"leave traits unchanged," and the SDK expresses that by omitting the key
from the request rather than sending
traits: {}. The server now treats an explicit empty object the same way, as a no-op, so neither shape can wipe a profile populated earlier. A bareidentify(userId)after login is always safe; there is no way to clear stored traits through this field at all. - There is no code generation in v1. 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.
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:
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:
- HTTP 401, with any code or none (
INVALID_SDK_KEY,MISSING_SDK_KEY,SDK_KEY_IN_QUERY, ...), or - HTTP 403 whose code (top-level
code, orerror.code) isINVALID_SDK_KEY,MISSING_SDK_KEY,SDK_KEY_IN_QUERYorTENANT_KEY_MISMATCH.
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:
- reports it once, as an error-level
invalid_sdk_keydiagnostic whosecauseisContentFlowError.InvalidKey(status, code, droppedCount)(a subtype ofAuthenticationFailed; a code-less 401 reportsINVALID_SDK_KEY). Error level is visible at the defaultDiagnosticsLevel.Errors, debug or not; - stops the live-updates stream and cancels periodic sync, with no reconnect;
- drops every queued item (string signals and device location points, including
any on the wire) and stops location collection;
droppedCountsays how many; - never sends another request with that key:
sync(),fetchBlock()and other calls fail fast withInvalidKey(cached content is still served where it was before), andtrack()returnsTrackingDisposition.Rejectedwithout queuing or logging again.
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
// 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
- 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. - Confirm the server has content for that key:
curl -H 'X-CF-Key: <workspace-id>_test' https://api.contentflow.click/sdk/v1/sync(an emptyblocksarray means nothing is published for this key yet). - Turn on diagnostics with
diagnostics = DiagnosticsLevel.Verbose(see Diagnostics). - Smoke test:
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:
gradle --no-daemon --max-workers=2 test assembleThat 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).
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
- minSdk 24, compileSdk 34, JVM target 17
- Kotlin 2.0, coroutines 1.9
- OkHttp 4.12 (chosen over Ktor: it is already on nearly every Android classpath through Retrofit or Coil, it streams SSE without an extra dependency, and it runs unchanged on the JVM for tests)
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):
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.
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:
- Pins apply to the host in
baseUrl(by defaultapi.contentflow.click), and to every request the SDK makes there, including the live-updates stream.baseUrlmust behttps://. - A pin is the SHA-256 of a certificate's public key (SubjectPublicKeyInfo) in
sha256/<base64>form; bare base64 is also accepted. A renewed certificate that keeps the same key still matches. - Hex certificate fingerprints are rejected: they change at every renewal and never match a public-key check.
- OkHttp verifies the normal certificate chain and hostname first. It then requires one pin to match a key in the verified chain (leaf, intermediate or root) before any request byte is sent.
- At least two distinct pins are required. Constructing the configuration throws
IllegalArgumentExceptionon fewer than two pins, a malformed pin, or a non-httpsbaseUrl. Pinning is never switched off silently.
Getting pins. For the server's current key:
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 | base64Prefix 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
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()Popup frequency control
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.
// 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- Tracking counts automatically: block impressions, taps, CTA clicks, dismissals and conversions you already track (the Compose slot tracks impressions), and campaign events sent with
trackCampaign. The local count works without analytics consent (nothing leaves the device); the server-side caps need analytics consent, because the server only counts events it receives. - Windows are in the workspace time zone (
config.frequency.timezone, Asia/Riyadh by default): "once per day" resets at local midnight. A session ends after 30 minutes or more in the background. - Interruptive items also share the workspace popup limits (
config.frequency.fatigue: per session, per day, minimum gap), unless the rule bypasses them (for example a regulatory notice). - Counts are kept per signed-in user, else per device; a login keeps what was counted anonymously on the device. The ledger holds counters and timestamps only, never content, next to the SDK's other small state, and is cleared on
logout()anddeleteIdentity(). - The
frequency_capsworkspace switch off makes the SDK ignore its ledger. - One session id per session: the SDK sends it as
X-CF-Session-Idon sync, single-block and campaign reads, and ascontext.sessionIdon every event batch, so the server can enforce session limits and build session funnels. It is the same id the ledger's session uses, so it changes exactly when the ledger starts a new session (a cold start, or 30 minutes or more in the background,logout(), a successfuldeleteIdentity()andresetFrequency()), never per request or per batch. Events are sent when tracked, so a batch carries the id of the session it was sent in. The id is random (sess_+ 16 hex), carries no user data and is not stored across launches.
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):
// 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)- One at a time. While a popup is on screen (or being decided),
presentPopupreturnsreason = AlreadyShowing.screenChangedcancels a presentation that is still pending (delay or fetch) when a newer screen arrives; nothing ever stacks. - Results.
kindisCampaignorBlock;reasonisNone(nothing eligible),AlreadyShowing,NotHandled(a block popup with noonBlockPopupthat the SDK does not draw: no standard fields,blockPopups = Off, or a custom presenter withoutpresentBlock),FeaturesOff(nothing eligible and thecampaignsswitch is off) orNoUiContext(noContentFlowPopupHostin composition). - Tracking is automatic, exactly once per presentation:
impressionwhen the popup appears, then exactly one ofcta_click(CTA tapped; the popup closes, thenonActionor the default open runs) ordismiss(close button, backdrop tap, system back including predictive back). These go throughtrackCampaign, so they count toward the campaign's frequency rule (stopOn: cta_click/dismiss) and keep the usual analytics-consent gating. - Links. Only
http,httpsand app custom schemes are opened;javascript:,data:,file:,vbscript:,intent:,content:,blob:andabout:are ignored (thecta_clickis still tracked). The default open is anACTION_VIEWintent. - UI. Centered card (max 400dp) on a 50% black backdrop: optional image (https only, at most 40% of the screen height, left out if it fails to load), bold title, scrollable body, CTA button only when the campaign has a CTA label, and a 48dp close button at the top end corner. Right-to-left (
ar,he,fa,ur,ps,sd,yi,ckb) follows the locale the campaign text resolved in; the close button is labelled "Close" or "إغلاق". Text sizes are insp(font scaling), insets are respected, there is no animation, and the card exposes a pane title, a heading and button roles to TalkBack.ContentFlowPopupHost(..., fontFamily = ...)sets the font. - Your own UI. Pass
renderCampaignPopup = { request -> ... }toContentFlowPopupHostand callrequest.onShown(),request.onCtaClick()andrequest.onDismiss(); or attach your ownCampaignPopupPresenterwithclient.popups.attach(...)(View-based apps). Leaving composition releases the popup without tracking a dismissal.client.closePopup()ends the popup on screen the same way (nocta_click, nodismiss) and frees the slot, for when the app itself must remove it. Events are sent in order: theimpressionalways precedes the terminal event.
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):
client.trackCampaignImpression(campaign)
client.trackCampaignClick(campaign) // cta_click
client.trackCampaignDismiss(campaign) // dismiss
client.trackCtaClick(block) // block cta_click
client.trackDismiss(block) // block dismissExtended 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
registerBlockorsyncBlocks. They send each definition nested underdefinition, which the server does not read, andregisterBlocksends a one-entry manifest to/cards/sync, which archives every other active block type in the workspace. Register block types withPOST https://api.contentflow.click/sdk/v1/cards/registerfrom CI instead (see the SDK guide).
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)
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.unknowndisableStrings 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.
// 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:
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))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 batchedTracking 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):
- Location is analytics: nothing is collected or sent without analytics consent. Revoking consent stops updates and discards unsent points.
deleteIdentity()stops collection and discards unsent points before the erasure request, after waiting for any upload already in flight.logout()or a different user identifying sends the outgoing user's points as that user (or drops them); they are never sent under the next user.close()stops and discards.
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.
// 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:
declare it in its own manifest:
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />request it at runtime on Android 10+, after fine location and in a separate step ("Allow all the time" in Settings on Android 11+);
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.
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.
- At most 100 regions per app (Android's limit), the server's
maxRegions, and minusreservedAppRegions(Android cannot list the regions your app registered itself, so tell the SDK how many to leave free). - When not every fence fits, the SDK registers the nearest ones plus one boundary region around the current position. Leaving it re-fetches with the new coarse position and re-selects. Selection uses the OS's last known location only; the SDK never requests a fix for it.
- Polygons arrive as their bounding circle and are registered as that circle; the server confirms the exact shape.
- Enter and exit are always monitored and reported for every fence, whatever
its trigger settings: the server needs the exit to close a stay, and the
trigger settings only decide what fires server-side. Dwell is monitored and
reported only for fences with dwell on, using the OS
(
GEOFENCE_TRANSITION_DWELLwithsetLoiteringDelay(dwellMs)). Enter fires at registration when the device is already inside (the server deduplicates). - Each event carries the OS transition time; the server rejects reports more
than 5 minutes in the future or more than 24 hours old. A dwell the SDK
computes itself (custom monitors without OS dwell) is timestamped at
enter +
dwellMs(or now, if earlier), and skipped if that is more than 24 hours ago. - Fences are refetched on start, on foreground once
refreshAfterSechas passed, and after leaving the boundary region. Start and foreground refetches send the ETag, and a 304 keeps the registered regions. The refetch after leaving the boundary is sent without the ETag. Sending it would also be correct (the server hashes the list for the rounded position, so a 304 means the nearest set for the new position is unchanged); skipping it costs one full response per move. - The SDK asks for as many fences as it has room for. When the server answers
truncated: true(more fences exist), it registers one fewer plus the boundary region; withtruncated: falseit registers everything, so at Android's cap a full 100 withtruncated: falseuses all 100 slots. An older server that doesn't sendtruncatedis detected: a full answer then counts as "more exist", and later requests ask for one more than fits until a response carriestruncatedagain. - Reporting follows the location route's rules: a refusal (device not identified, no consent) is dropped; a network error, 5xx or 429 is kept (up to 50 events) and retried on the next transition or foreground.
- A server without the route answers 404: geofencing stays idle quietly, with no base-URL error. A 502 or 503 keeps the registered regions and retries later; a 200 with no fences unregisters everything.
- The fetch sends only
lat,lng(together, rounded to 3 decimals, from the last known position) andmax. close()detaches without unregistering; callstopGeofencing()to remove the OS regions.- Play services keeps geofences after the process dies, so the SDK registers
every geofence with one fixed
PendingIntent(same request code, action and receiver in every process). Once per process, before its first registration, and on every stop or gate closing, it callsremoveGeofences(pendingIntent), which removes the SDK's geofences from earlier launches too. That includes a launch where the gate is closed at start, andstopGeofencing(context)in a process that never started geofencing (the plainstopGeofencing()only reaches the OS once this process has started). Your app's own geofences use yourPendingIntentand are never touched.
Custom monitors (tests, other platforms): implement GeofenceMonitor and pass
it to client.startGeofencing(monitor, options).
Event Tracking (Unified Model)
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:
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 |