Changelog
Unreleased
2.4.1 — 2026-10-11
Action needed
- Block popups can now be drawn by the SDK. An app that calls
presentPopup or uses PopupConfig(autoPresent = true) without onBlockPopup may now see a block popup it previously skipped (NotHandled in 2.4.0), when that block uses the standard fields (see Added). To keep the 2.4.0 behaviour, set PopupConfig(blockPopups = BlockPopupMode.Off). An app that sets onBlockPopup sees no change.
- Block deep links go through
onBlockAction. onAction stays typed to Campaign and is not called for a block card. If you set onAction to route your own links, also set PopupConfig(onBlockAction = { block, url -> ... }): without it, a block link with an app custom scheme is not opened (http and https still are).
Fixed
- A dismissed in-app message no longer comes back when analytics consent is not granted. The server removes an in-app message only when it receives the
dismiss / cta_click event, which is sent only with analytics consent, so without consent the message returned on every sync until it expired. Tracking dismiss, cta_click or tap on an in-app messages (instance ids starting jm_, delivered under journey_message or the placement you chose) now also records its instance id on the device, whatever the consent, and sync(), fetchBlock, observeBlock (and so ContentFlowSlot), ContentUpdate and getPopup leave it out from then on, across restarts. An observed message placement moves to the next pending message at once. Only instance ids and times are stored (at most 200, each for 8 days); they are cleared by logout(), a different user identifying, deleteIdentity() and resetFrequency(). Event sending is unchanged: still only with consent. When several messages are pending, observeBlock("journey_message") and the cached single-block read now both hold the newest (first) one. No API change.
- Tests only:
DismissedMessagesTest no longer fails intermittently; its mock server now answers the inbox request the client makes after a sync. No change to the SDK.
Added
- The built-in popup card also draws standard block popups. When
getPopup chooses a block popup and the app set no onBlockPopup, a block with a non-empty #title or #body is drawn in the same card as campaigns (same theme, layout, RTL, accessibility, one-at-a-time, delay, link rules and https-only images). Fields: #title, #body, #image (image field or URL text), #cta_label, #cta_url, and #cta_visible (a toggle, or the text "false", hides the button). Missing image or CTA leaves them out. The result is PopupPresentation(shown = true, kind = Block, id = <instanceId>). A block without those fields still returns NotHandled; onBlockPopup always wins.
- Tracking is automatic and exactly once per presentation:
impression, then one of cta_click or dismiss, sent through the block event path (block key + instance id), so they count toward the block's frequency rule, campaign statistics and the dismissed in-app message state, exactly like trackCtaClick(block) / trackDismiss(block).
- New
PopupConfig fields (added at the end, so existing calls compile): blockPopups: BlockPopupMode (BuiltIn default, Off) and onBlockAction: ((ContentBlock, String) -> Boolean)?, the onAction counterpart for a block's #cta_url (onAction stays typed to Campaign). When it returns false the SDK opens the URL, as for campaigns. Without onBlockAction the SDK opens the block link itself, except that when onAction is set (the app routes its own links) a block link with an app custom scheme is not opened (http and https still are): set onBlockAction to route block deep links. cta_click is tracked either way.
CampaignPopupRequest now extends a new sealed PopupCardRequest (title, body, imageUrl, ctaLabel, theme, isRtl, closeLabel, onShown/onCtaClick/onDismiss/release); a block is a BlockPopupRequest (block, actionUrl). CampaignPopupPresenter gains presentBlock and dismissBlock with defaults: a presenter written for 2.4.0 does not draw blocks (NotHandled). Compose: ContentFlowPopupHost draws blocks, and CFBlockPopup(request) is public. renderCampaignPopup applies to campaigns only; blocks always use the built-in card.
2.4.0 — 2026-10-10
Action needed
ContentFlowEnvironment has a new value, Unknown. A created publishable key (<workspaceId>_cfpk...) now reports Unknown on client.environment instead of Live (see Fixed). An exhaustive when over ContentFlowEnvironment (the enum is public) no longer compiles until you add an Unknown branch.
ContentFlowClient has new abstract members (trackCampaign, frequencyReason(block), frequencyReason(campaign), getPopup, resetFrequency). Code that implements the interface itself (e.g. a test fake) must add them (see Changed).
- On-device geofencing needs an explicit location grant (#1030).
startGeofencing now stays idle until the app calls setChannelConsent(ConsentChannel.Location, ConsentStatus.Granted) (in this launch or an earlier one); location consent that was never asked no longer counts as granted. If your app starts geofencing without asking for location consent, ask first. This matches the JS and Swift SDKs.
Fixed
- One session id per session, sent on content reads.
X-CF-Session-Id now goes on /sdk/sync, /sdk/blocks/{key} and /sdk/campaigns, with the same value as context.sessionId on event batches. The id was fixed for the client's lifetime; it now rotates with the popup-frequency ledger's session (cold start, 30 minutes or more in the background, logout(), a successful deleteIdentity() and resetFrequency()), so the ledger session, the event session and the header cannot disagree. No API change.
- A created test key (
<workspaceId>_cfpk...) is no longer treated as live. The environment of a created publishable key is stored on the server and is not in the key text, but the SDK read it from the suffix, so a test key reported Live. ContentFlowEnvironment gains Unknown, which is what a _cfpk key (workspace id + _cfpk + 40 hex) now reports on client.environment (ContentFlowKeys.environmentFor); nothing in the SDK assumes live for it, and its cache namespace is separate from live and test. <workspaceId>_test and pk_test_... are still Test; <workspaceId>_app and pk_live_... are still Live. The server does not report the environment yet, so a created key stays Unknown. See Action needed for exhaustive when.
- Every channel grant (push, SMS, WhatsApp, email, marketing, location) is saved only once the server confirms it: a failed, timed-out or unconfirmed write (
applied/updated/results false, or an echoed value that differs) throws and leaves the device unchanged. A revoke made while a grant of that channel is in flight wins: the device stays denied and the revoke is written after the grant. Channel writes now run one at a time.
- A channel revoke made before
identify(), or whose write failed, is kept on the device and sent by the next identify() with a user, also after a relaunch. setChannelConsent no longer throws for a revoke without a user (a grant still does).
getConsentConfig(preset) keeps the whole preset (#1082). It returned only the id and required, dropping what a consent screen needs. ConsentPreset now also has optional, industry, region, defaultTopics, availableTopics (ConsentPreset.Topic: id, name, nameAr, required) and legalFramework, read from the route's real shape, plus preset as an alias of id. Existing fields and constructor calls are unchanged; the nested channels/marketing/location are documented as legacy (the server never sends them).
- On-device geofencing (#1030): the gate requires location consent granted (unset and revoked keep it idle). A revoke takes effect before the write and is saved on the device even if the write fails or no user is identified yet, unregistering every SDK region now and after a relaunch. A grant takes effect, and is saved, only once the server confirms it: a grant whose write fails does not start geofencing in that launch or a later one, is not sent as granted by a later write for another channel, and can be retried at once.
client.consent shows a location grant only after that confirmation.
Added
- Core workspace switches on
SdkFeatures: contentSync, strings, analyticsEvents, pushPreferences, geofences, profileUpdates (wire keys content_sync, strings, analytics_events, push_preferences, geofences, profile_updates). ContentFlow can now turn these off for one workspace or for every workspace during an incident; the server handles each one with a response the SDK already treats as success. With analytics_events off the SDK also drops events on the device: track(...) returns TrackingDisposition.Rejected with a reason starting feature_disabled (no new disposition, so exhaustive whens keep compiling), and string-sensor signals are discarded.
- Popup frequency control (#1049). The server already withholds a block or in-app campaign that is capped for the user or device; the SDK now keeps a local ledger with the same rule, so caps also hold before an event reaches the server, offline, from cache and within a session. Block events you already track (
trackEngagement / track(CFEngagementEvent) with impression, tap, cta_click, dismiss, conversion, the Compose slot's automatic impression included) update it without new calls.
client.shouldShow(block | campaign) and client.frequencyReason(...) (SuppressionReason: StoppedOnCtaClick | StoppedOnDismiss | StoppedOnConversion | CappedImpressions | CappedClicks | CappedSession | Cooldown | WorkspaceGuard).
client.getPopup(screen) (suspend): at most one interruptive block or in-app campaign for the screen (Popup.Block / Popup.Campaign), highest priority first, after its rule and the workspace popup limits (config.frequency.fatigue: per session, per day, minimum gap). Campaigns are fetched for the placement screen while the campaigns switch is on.
client.trackCampaign(campaign, EngagementType): campaign impressions, taps, CTA clicks, dismissals and conversions (POST /sdk/events with campaignId, placementId and, for an A/B test, variantId + experimentId). This SDK had no campaign tracking before; it is what counts a campaign toward its rule, here and on the server.
client.resetFrequency() (debug builds) forgets the local counts so a "once ever" popup can be tested again.
- Windows use the workspace time zone from
config.frequency.timezone (Asia/Riyadh by default, also for an unknown zone): "once per day" resets at local midnight. A login keeps what was counted anonymously on the device; the ledger is cleared on logout() and a successful deleteIdentity(). A session ends after 30 minutes or more in the background. It stores counters and timestamps only, never content, in a small file next to the consent and segment state, one per key, tenant, environment and device.
ContentBlock.frequency / ContentBlock.priority, Campaign.frequency (read from frequency, else rules.frequencyCapping from older servers), SdkFeatures.frequencyCaps (the frequency_caps workspace switch: off means the SDK ignores its ledger), new types FrequencyRule, FrequencyPeriod, FrequencyStopEvent, FrequencyConfig, FrequencyFatigue, SuppressionReason, Popup.
- SDK-drawn campaign popup. In-app campaigns chosen by
getPopup(screen) can be drawn by the SDK; blocks stay app-drawn.
- Compose:
ContentFlowPopupHost(client, screen) attaches the UI and, with auto-present on, presents the screen's popup; CFCampaignPopup(request) is the dialog itself (dimmed backdrop, image/title/body/CTA, close button at the top end, RTL from the campaign's locale, TalkBack semantics, back/predictive back, insets, font scaling). Pass renderCampaignPopup = { request -> ... } to ContentFlowPopupHost to draw your own UI instead.
- Core:
client.presentPopup(screen) returns PopupPresentation(shown, kind, id, reason) (None, AlreadyShowing, NotHandled, FeaturesOff, NoUiContext); client.screenChanged(screen) presents after PopupConfig.delayMs (0..60000) and a newer screen cancels a pending one; one popup at a time. client.popups.config = PopupConfig(autoPresent, delayMs, theme, onBlockPopup, onAction): autoPresent is off by default, so upgrading never shows new UI. CampaignPopupPresenter for a non-Compose UI.
- Tracked automatically, once per presentation:
impression, then exactly one of cta_click or dismiss, through trackCampaign (frequency ledger and consent gating unchanged). Action URLs: only http, https and app schemes are opened (javascript:, data:, file:, vbscript:, intent:, content:, blob: and about: are blocked); onAction handles deep links first.
client.closePopup() ends the current popup without tracking (no cta_click, no dismiss) and frees the slot for the next one. Campaign popup images load over https only. The built-in popup UI is Compose-only: a View-based app attaches its own CampaignPopupPresenter with client.popups.attach(...) (without one, presentPopup returns NoUiContext), or draws getPopup results itself with the tracking helpers.
- Tracking helpers (parity with the JS SDK):
trackCampaignImpression, trackCampaignClick (cta_click), trackCampaignDismiss, and block trackCtaClick(block) / trackDismiss(block). Thin wrappers over trackCampaign / track; cta_click and dismiss count toward a rule's stopOn.
Changed
- Every request now also declares
frequency-v1 in X-CF-SDK-Capabilities (erasure-partial-v1,frequency-v1), so analytics can tell which clients enforce frequency locally. Informational only: server-side caps also apply to older SDK versions.
ContentFlowClient has new members; trackCampaign, frequencyReason (block and campaign), getPopup and resetFrequency have no default, so a custom implementation must add them (see Action needed). shouldShow has a default. The popup members (popups, presentPopup, screenChanged) and the tracking helpers have default implementations.
2.3.0 — 2026-10-03
Action needed
- On-device geofencing needs
ACCESS_BACKGROUND_LOCATION from your app (#989). The :geofencing module's manifest declares only ACCESS_FINE_LOCATION and ACCESS_COARSE_LOCATION. To use geofencing, declare ACCESS_BACKGROUND_LOCATION in your app's manifest, request it at runtime on Android 10+, and complete Play's background location declaration; without it geofencing stays idle (see Added).
- A rejected key stops the client for that key (#1006). An HTTP 401, or a 403 with
INVALID_SDK_KEY, MISSING_SDK_KEY, SDK_KEY_IN_QUERY or TENANT_KEY_MISMATCH, stops the stream, periodic sync and location for good and drops every queue; later calls fail with ContentFlowError.InvalidKey without a request. Only a new client with a valid key, or an app restart, resumes. A 403 TENANT_KEY_MISMATCH is now InvalidKey (an AuthenticationFailed), no longer AuthorizationFailed: move any handling of it (see Fixed and Changed).
- License. 2.3.0 is the first release under the proprietary ContentFlow SDK License (
LICENSE in the package); earlier releases keep their terms. Read it before you upgrade (see Changed).
Fixed
- A rejected SDK key now stops the client instead of retrying forever (#1006). The key is dead on HTTP 401 (any code) or on 403 with
INVALID_SDK_KEY, MISSING_SDK_KEY, SDK_KEY_IN_QUERY or TENANT_KEY_MISMATCH (top-level code or nested error.code); other 403s such as DEVICE_NOT_IDENTIFIED are per-call refusals and change nothing. On a dead key the client stops the live-updates stream and cancels periodic sync for good, stops location collection, drops every queued string signal and location point (in-flight ones included), and reports one error-level invalid_sdk_key diagnostic whose cause is the new ContentFlowError.InvalidKey(status, code, droppedCount). Later calls fail fast with InvalidKey without touching the network, and track() returns Rejected without logging again. Resume by starting a new client with a valid key or configuration, or by restarting the app; nothing is persisted. Write-key routes never trip it.
- Retry queues are bounded (#1006): string signals and device location points keep at most 500 items and drop the oldest (location used to keep 100 and drop the newest). Drops are reported as a
queue_dropped warning at most once a minute, with the count since the last report. Location points are sent at most 100 per POST /sdk/location (the gateway refuses larger requests), oldest first, in back-to-back requests: a failed request keeps itself and everything after it for retry while earlier ones stay delivered, and a refused one drops only its own points.
- Retries (#1006): only transport failures, 429 and 5xx are retried, with exponential backoff and jitter (1 s base, x2, capped at 5 min; reads were 0.5 s to 8 s).
Retry-After is now the minimum wait rather than a replacement for the backoff, accepts an HTTP date as well as seconds, and is honoured up to 1 hour (was 5 minutes). String signals used to be re-queued after any failure, without bound and with no backoff; location points were re-queued after any failure too. Both now drop on any other 4xx (identity and consent refusals included) and retry on a backed-off timer instead of on the next tracked item.
Added
- On-device geofencing (#989): new opt-in
:geofencing module (client.startGeofencing(context) / stopGeofencing() / isGeofencing(), Google Play services GeofencingClient). Fetches the workspace's fences from GET /sdk/geofences (nearest first, ETag), registers the nearest ones within Android's 100-region limit (minus GeofencingOptions.reservedAppRegions, plus one boundary region for re-selection as the device moves), and reports transitions to POST /sdk/location with source: "device" and the OS transition time: enter and exit always (the server needs the exit to close a stay; trigger settings only decide what fires server-side), dwell only for fences with dwell on (OS dwell via setLoiteringDelay). The fetch sends only lat/lng (together, 3 decimals) and max; a 502/503 keeps the registered regions, an empty list unregisters them. The SDK asks for as many fences as it has room for and uses data.truncated to decide whether a boundary region is needed (an older server without truncated is detected and probed with one extra). The refetch after leaving the boundary region is sent without the ETag. A dwell computed on the device is timestamped at enter + dwellMs and skipped when older than 24 hours. Geofences from earlier processes are removed (removeGeofences with the SDK's fixed PendingIntent) once per process before the first registration and on every teardown, so they never pile up toward the 100 limit; the app's own geofences are untouched. client.stopGeofencing(context) (core: stopGeofencing(monitor)) sweeps them even in a process that never started geofencing. The module's manifest declares only ACCESS_FINE_LOCATION and ACCESS_COARSE_LOCATION: ACCESS_BACKGROUND_LOCATION is left to the app (declare it, request it on Android 10+ and complete Play's background location declaration; without it geofencing stays idle), so depending on the module no longer forces Play's background-location declaration. Fences have no name (removed from GET /sdk/geofences in #1021); an older server's name is ignored. App-defined fences (registerGeofence) whose ids the dashboard doesn't have no longer reach analytics from device reports. Polygons are registered as their bounding circle. Runs only after the app starts it, with analytics consent, the location switch on and location consent not revoked; any of these turning off unregisters every region. Refusals are dropped, network errors retained. A gateway without the route (404) leaves it idle quietly. No continuous GPS; the SDK never requests permission (the app requests fine + background location). GeofenceMonitor in :core for custom monitors and tests.
- New
ContentFlowClientExtensions.disableStrings(keys, writeKey): stop tracking these localization keys; their text is still served. It sends POST /api/v1/strings/disable with X-CF-Write-Key (API root derived from the base URL, like registerStrings) and returns StringDisableResult(disabled, alreadyDisabled, unknown). Their sensors turn off, so the SDK stops sending string events for them once the next strings refresh drops them from smartKeys. Turn tracking back on in the portal (Sensors → Enable); there is no SDK call for it, and registerStrings never changes tracking. Takes 1 to 500 non-empty keys of at most 200 characters (duplicates dropped) and throws IllegalArgumentException before sending anything otherwise; request failures throw ContentFlowError, like registerStrings. Write key only: call it from a server or CI job, never from a shipped app. Needs the management API's /strings/disable route.
- Workspace feature switches (#907): the client reads
config.features from /sync and honours them. live_updates off closes (or never opens) the live-updates stream and falls back to ETag polling; campaigns off makes getCampaigns return an empty list with no request; location off pauses tracking and drops unsent points. A missing or invalid switch counts as on, a 304 keeps the last value, and turning a switch back on only resumes what the app had started. New client.features (SdkFeatures) and client.featuresFlow. A switch never changes the base URL, keys, authentication, pinning or consent.
Changed
ContentFlowError.InvalidKey is a subtype of AuthenticationFailed (now open), so existing AuthenticationFailed handlers keep working. A 403 TENANT_KEY_MISMATCH used to be AuthorizationFailed and is now InvalidKey.
- Every request, the live-updates stream included, now sends
X-CF-SDK-Capabilities: erasure-partial-v1 (it was empty). deleteIdentity() already throws and keeps the device id and local identity on a partial erasure (a 207 with success: false), so the gateway now relays the 207 to this SDK instead of a 502 (#968). The SDK still never declares sponsored-surface-v1.
- License: the SDK is now licensed under the ContentFlow SDK License (proprietary). Earlier releases' license terms are unchanged.
Deprecated
- Deprecated
getIndustryProfile(). It now returns null without making a request. It used to call GET /sdk/industry-profile, which the gateway never had. Industry data is write-only from the SDK: send it with updateIndustryProfile(), and ContentFlow uses it server-side. Remove calls to it; it will be removed in the next major version (#932).
2.2.2 — 2026-10-01
Action needed
- Remove
SecurityConfig.enableRequestSigning and signingSecretKey, ContentFlowConfiguration.offline (OfflineConfig) and SecurityConfig.enableSecureStorage from your configuration. They are deprecated no-ops (see Deprecated); they still compile, but do nothing.
Fixed
ContentFlowDiagnostics.setLevel(...) set by the app was overwritten by start(), which always applied configuration.diagnostics (default Errors), so setLevel(DiagnosticsLevel.Verbose) produced no debug output (#894). A level the app sets now wins, before or after start(), and survives later starts; until the app sets one, start applies the configured level. New ContentFlowDiagnostics.resetLevel() hands control back to the configuration.
- README: the diagnostics code
analytics_dropped_no_consent never existed; the real code is events_dropped_no_consent. A unit test now checks that every code the README names is reported by the SDK.
Added
- Every request, the live-updates stream included, sends
X-CF-SDK-Version: kotlin/2.2.2 (from SDK_VERSION) and an empty X-CF-SDK-Capabilities (#903). The SDK declares no capabilities, so it never receives sponsored content.
Deprecated
- Request signing does nothing, and never did: no ContentFlow server verifies
X-CF-Signature/X-CF-Timestamp (#896). SecurityConfig.enableRequestSigning and signingSecretKey are deprecated no-ops. The SDK no longer sends the headers, no longer requires a secret when the flag is on, and reports one request_signing_unsupported error diagnostic per client instead. Remove both options, and never ship a secret in an app.
OfflineConfig (ContentFlowConfiguration.offline) is a deprecated no-op. There is no offline event queue: an event that cannot be delivered is reported as TrackingDisposition.Rejected and is not stored. The placeholder queue (which nothing ever fed) and its connectivity listener are no longer created.
SecurityConfig.enableSecureStorage is a deprecated no-op. The encrypted store it created was never read or written. The androidx.security:security-crypto dependency is removed from :android.
2.2.1 — 2026-09-30
Action needed
- The default base URL changed from
https://api.contentflow.click/api/v1 to https://api.contentflow.click/sdk/v1. The host is the same, so certificate pins are unaffected. An explicit …/api/v1 or …/api/v1/sdk base keeps working.
- A base URL that doesn't end in
/sdk/v1 now reports one base_url_legacy error diagnostic per client (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).
Changed
DEFAULT_BASE_URL is https://api.contentflow.click/sdk/v1. Routes join the base without doubling /sdk; write-key strings/sync goes to the API root derived from the base (…/sdk/v1 → …/api/v1). The InvalidBaseUrl guard is unchanged.
Added
- Base URL configuration without code. Precedence: an explicit
baseUrl > platform config > the default.
- Platform config (first non-empty wins): the Android manifest
<meta-data android:name="click.contentflow.BASE_URL">, then the JVM system property contentflow.baseUrl, then the environment variable CONTENTFLOW_BASE_URL.
- Validation: a configured value must be https (http only for localhost, 127.0.0.1, 10.0.2.2); an invalid value is rejected with a
base_url_config_rejected diagnostic and the default is used.
- API:
ContentFlowBaseUrl exposes the constants and the platform-source hook.
2.2.0 — 2026-09-26
Breaking / action needed
ContentFlowClient gained members (extensions, logout, unregisterPushToken, deleteIdentity, getCampaigns). Code that implements the interface itself (e.g. a test fake) must add them.
TrackingDisposition has a new case, Rejected(reason). An exhaustive when over it needs a branch. Failed or refused events used to be reported as Queued (or DroppedNoConsent for auth errors) even though nothing was buffered; they now return Rejected.
Added
- Device location capture in a new opt-in
:location module (enableDeviceLocation(context), platform LocationManager, no Play Services): requestLocation, startLocationTracking/stopLocationTracking, registerGeofence/unregisterGeofence (circle + polygon; enter/exit/dwell computed on the device), and locationPermission() (precise/approximate/denied/unavailable). Fixes are filtered (accuracy, movement, mocked), batched and sent to POST /sdk/location. The SDK never requests permission. LocationProviding and setLocationProvider in :core allow custom sources. Privacy: gated on analytics consent; revoke, deleteIdentity(), logout/user switch and close() stop collection and discard or send-as-outgoing-user exactly as in the Swift and JS SDKs.
- A one-time Android warning when
registerBlock/syncBlocks/registerStrings run inside an app (as in the JS and Swift SDKs); KDoc says these are server/CI only.
ContentFlowClient.extensions: the extended API (locales, string sensors, push topics and preferences, consent presets and full consent, visitor id, IP geolocation, location batches, domain profile, block/strings registration) is now reachable. It was implemented but never constructed.
logout(), unregisterPushToken(), deleteIdentity() (GDPR erasure via DELETE /sdk/identify) and getCampaigns(placement, locale) with a Campaign model.
Fixed
- Logging out,
deleteIdentity(), or a different user identifying now clears the content cache (disk and memory). The cache is not keyed by user, so the previous user's targeted blocks were otherwise served to the next user.
- Identity events (
trackIdentity, CFIdentityEvent) go to POST /sdk/track-event; the gateway refuses them on /sdk/events.
- String sensors are sent as
string_impression/string_interaction events on POST /sdk/events (was a non-existent /sdk/string-sensors), and only with analytics consent.
- Location batches and geofence events go to
POST /sdk/location in the gateway's locations[]/geofenceEvents[] shape (were /location/batch, /sdk/location/batch, /sdk/geofence-event).
- IP geolocation calls
GET /sdk/geo/ip (was /geo/ip).
updateIndustryProfile writes the user's domain profile via POST /sdk/profile (was a non-existent /sdk/industry-profile).
- README:
CFEngagementEvent.of (not EngagementEvent.of), and event examples that use the real API.
Not available yet
submitKYC, getKYCState and getIndustryProfile: the public SDK gateway has no route for them.
2.1.0 — 2026-09-24
Breaking / action needed
ContentFlow.offlineQueue and ContentFlow.secureStorage are now internal. They never compiled before 2.1.0, so no app could have used them.
CFCertificatePinner is removed from the public API; use SecurityConfig(enableCertificatePinning = true, pinnedCertificates = ...).
CommerceEvent.booking(...) now defaults status to TransactionStatus.Completed (the removed Confirmed value never existed on the wire; the value sent is unchanged).
Fixed
- The SDK compiles and its tests pass:
:core, :android and :compose build, and the :core suite runs in CI on every PR. The 2.0.1 Kotlin archive did not compile.
registerStrings posts to {baseUrl}/strings/sync and syncBlocks/registerBlock to {baseUrl}/sdk/cards/sync, the real write-key routes; registerStrings now sends sourceText, so strings are no longer saved with empty text. Both need a write key (X-CF-Write-Key) and are server-side tools; never ship a write key in an app.
SDK_VERSION tracks the release (it said 1.0.0).
Security
- Certificate pinning (off by default) pins the
baseUrl host by public key (sha256/<base64> SPKI), in the core transport, so every client is pinned: Android, JVM clients from ContentFlowClientFactory, and a caller-supplied httpClient. At least 2 distinct pins are required (current + backup); an invalid setup throws at configuration time instead of silently disabling pinning (fail closed). Hex certificate fingerprints are rejected. A mismatched server receives no request.
- Only the
baseUrl host is pinned. Renew server certificates with the same key (e.g. certbot --reuse-key), or ship a backup pin for the new key in an app release before rotating it.
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.