Changelog
Unreleased
2.4.1 — 2026-10-11
Action needed
presentPopup / autoPresent may now show a block popup it used to skip. Without popups.onBlockPopup, a block popup that uses the standard fields is now drawn in the built-in card instead of returning not_handled (see Added). To keep the 2.4.0 behaviour, set CFPopupOptions(blockPopups: CFBlockPopupMode.off). An app that sets onBlockPopup sees no change.
popups.onAction can receive a block. For a block card its first argument is a CFBlockActionContext (a CFCampaign whose block is the block). Check item is CFBlockActionContext before treating it as a campaign.
Fixed
- A closed in-app message stays closed without analytics consent. A
dismiss, cta_click or tap on an in-app messages (instance ids starting jm_, delivered under journey_message or the placement you chose) now hides that message on the device at once, whether or not the event may be sent: getBlock, getBlocks, blocks, observeBlock, getPopup and the list sync() returns leave it out. Before, without consent the event was dropped and the message came back on every sync until it expired. The device keeps only the message's instance id and the time, for 8 days (at most 200), across restarts, and forgets them on logout(), a switch to another user, a complete deleteIdentity() and resetFrequency(). Nothing new is sent; the event is still sent only with consent.
- Every pending in-app message is kept. The SDK kept one block per key, so only one of several pending in-app messages was visible. It now keeps every instance the server delivers, in delivery order, and the new
getBlocks(key) lists them (same name as the JS SDK). getBlock(key) still returns one instance (the last delivered), as before; blocks, and the list sync() returns on a 304 or a failure, now hold every instance instead of one per key.
Added
- The built-in popup card also draws standard block popups. When
presentPopup(screen) or screenChanged + autoPresent finds a block popup and the app set no popups.onBlockPopup, a block with a non-empty #title or #body is drawn in the same card as an in-app campaign: same theme, layout, RTL, accessibility, one popup at a time, delayMs, CTA link rules and https-only images. Fields: #title, #body, #image (image URL), #cta_label, #cta_url; a missing image or CTA is left out, and #cta_visible false (a toggle, or the text "false") hides the button. The result is shown: true, kind: block, id: <instance id>. A block without those fields still returns not_handled, and onBlockPopup, when set, always wins.
- Tracked like a campaign popup, exactly once per presentation, through the block tracking path (
trackImpression, trackCtaClick, trackDismiss with the block key and instance id), so the frequency ledger, server counts, campaign statistics and the closed-message logic apply: impression when it appears, then cta_click (CTA: the card closes first, then the link goes to onAction) or dismiss (close button, backdrop tap, back).
popups.onAction keeps its signature. For a block, its first argument is a new CFBlockActionContext, a CFCampaign whose id is the instance id, whose type is block, and whose block is the block: if (item is CFBlockActionContext) item.block. Passed to a trackCampaign* call it is tracked as the block's event, never as a campaign event. As before, nothing opens without onAction.
- Direction and the close label follow
presentPopup(locale:), else the client locale, as for campaigns.
popups.renderCampaignPopup applies to campaigns only; a block always uses the built-in card. closePopup() also takes a block card down silently.
- New
CFPopupOptions.blockPopups (CFBlockPopupMode.builtIn, the default, or CFBlockPopupMode.off), CFBlockPopupContent, CFBlockPopupSession and CFPopupSession (the common base of CFCampaignPopupSession and CFBlockPopupSession), CFPopupContent (the base of CFCampaignPopupContent) and the CFPopupPresenter typedef.
2.4.0 — 2026-10-10
Action needed
- On-device geofencing needs an explicit location grant (#1030).
startGeofencing now stays idle until the app grants location consent with setChannelConsent(userId, CFChannelConsent(locationTracking: true)) or requestConsent(location: true) (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, Swift and Kotlin SDKs.
Fixed
- One session id per session (
X-CF-Session-Id). The session id was fixed for the whole client lifetime. It now rotates with the popup frequency ledger's session (cold start, 30 minutes in the background, resetFrequency(), logout() and a complete deleteIdentity()) and is sent as X-CF-Session-Id on sync, fetchBlock and getCampaigns, matching context.sessionId on event batches. Events queued before a rotation keep their own session id when sent later (one /events request per session); when a later request fails, only it and the ones not yet sent are retried, never a session the server already accepted.
getConsentConfig(preset) keeps the whole preset (#1082). It returned only the id and required, dropping what a consent screen needs. CFConsentPreset now also has optional, industry, region, defaultTopics, availableTopics (CFConsentPresetTopic: id, name, nameAr, required) and legalFramework, plus preset as an alias of id. Existing fields are unchanged; the nested channels/marketing/location are documented as legacy.
- On-device geofencing (#1030):
startGeofencing no longer clears a location revoke made through setChannelConsent or requestConsent; it only clears a server consent refusal of a report. Location consent is tracked as unset / granted / revoked, persisted on the device, and the gate requires granted. A revoke still takes effect before the request and stays in force if it fails, unregistering every SDK region; a grant takes effect once the server confirms it.
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, so the SDK only reports them.
- SDK-drawn campaign popup.
presentPopup(screen, context: ...) runs getPopup(screen) and shows an in-app campaign in a built-in modal card (CFCampaignPopupView: image, title, body, CTA, close button), tracking exactly one impression and then at most one cta_click or dismiss. A dismiss is tracked only when the user closes the popup (close button, backdrop tap, back); a close the SDK did not see the user make, such as the app popping the route, ends it silently. The image loads over https only (any other scheme is dropped). Block popups stay app-drawn: they go to popups.onBlockPopup, else the result is not_handled. One popup at a time (already_showing). Returns a CFPopupPresentation (shown, kind, id, reason: none, already_showing, not_handled, features_off, no_ui_context).
screenChanged(screen) presents after popups.delayMs when popups.autoPresent is on; a newer screen cancels a pending presentation. autoPresent is off by default, so upgrading shows no new UI. ContentFlowPopupObserver (a NavigatorObserver, route names as screens) and ContentFlowPopupHost call it for you.
- New
ContentFlowConfig.popups (CFPopupOptions): autoPresent, delayMs (0..60000), theme (CFPopupTheme), renderCampaignPopup, onBlockPopup, onAction, navigatorKey.
- The CTA opens nothing by itself (the SDK does not depend on
url_launcher): pass popups.onAction to open the link. Only http(s) and app custom schemes reach it; javascript:, data:, file:, vbscript:, blob: and about: are refused.
- RTL for Arabic and other RTL locales, dialog semantics, a localized close label, system text scaling, safe areas and reduced motion.
trackCampaignCtaClick(campaign) sends a campaign cta_click.
closePopup() takes down the SDK-drawn campaign popup with no dismiss or cta_click and frees the one-at-a-time slot; it does nothing when none is showing, and a pending autoPresent presentation is not cancelled.
- 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 the event batch is sent, offline, from cache and within a session. The track calls you already make update it:
trackImpression, trackCtaClick, trackEngagement (tap), trackDismiss and trackConversion.
shouldShow(item) and frequencyReason(item) (a CFSuppressionReason: cappedImpressions, cappedClicks, cappedSession, cooldown, stoppedOnCtaClick / stoppedOnDismiss / stoppedOnConversion, workspaceGuard) for a CFBlock, CFCampaign, CFPopup or cached block instance id.
getPopup(screen): at most one interruptive block or in-app campaign for the screen (a CFBlockPopup or CFCampaignPopup), highest priority first, after its own rule and the workspace popup limits (config.frequency.fatigue: per session, per day, minimum gap).
- Campaign tracking:
trackCampaignImpression, trackCampaignTap, trackCampaignConversion and trackCampaignDismiss send campaign events (campaignId, placementId) in the same shape as the JS SDK, and count for campaign rules. New CFEvent.campaign.
- Local counting works without analytics consent: the track calls update the on-device ledger even when the event itself is dropped, and nothing leaves the device. Server-side caps need analytics consent.
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), so "once a day" resets at local midnight. Dart has no time-zone database: Asia/Riyadh, the other fixed-offset Gulf zones (Kuwait, Qatar, Bahrain, Aden, Baghdad, Dubai, Muscat) and UTC are exact; any other zone is counted in the device's local time. A new session starts after 30 minutes in the background. A login keeps what was counted anonymously on the device; the ledger is cleared on logout() and a complete deleteIdentity(). It stores counters and timestamps only, in the per-device storage (obfuscated with enableSecureStorage).
CFBlock.frequency, CFCampaign.frequency and CFCampaign.priority (5 when absent), SdkFeatures.frequencyCaps (the frequency_caps switch: off means the SDK ignores its ledger), new types CFFrequencyRule, CFFrequencyConfig, CFFrequencyPeriod, CFFrequencyStopEvent, CFSuppressionReason, CFPopup.
Changed
- Requests now declare
frequency-v1 too: X-CF-SDK-Capabilities: erasure-partial-v1,frequency-v1, so analytics can tell which clients enforce popup frequency locally. Informational only: server-side caps apply to every SDK.
2.3.0 — 2026-10-03
Action needed
deleteIdentity() throws on a partial erasure (#968). A 2xx that isn't success: true with localWipeSafe: true (the gateway's 207) now throws CFRequestException and keeps the device id and local state; it used to wipe them as if the erasure had completed. Catch it and call deleteIdentity() again to finish the erasure (see Fixed).
- A refused 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, cancels sync and live updates and drops every queue; the client makes no further request with that key and reports CFInvalidKeyException. Only a new client with a valid key, or an app restart, resumes (see Fixed).
- 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 refused SDK key stops the client instead of retrying forever (#1006). On an HTTP 401 (any code), or a 403 with
INVALID_SDK_KEY, MISSING_SDK_KEY, SDK_KEY_IN_QUERY or TENANT_KEY_MISMATCH, the client cancels periodic sync, closes the live-updates stream, drops every queued event (memory and offline queue) and unsent location point, and makes no further request with that key. It reports one new CFInvalidKeyException (a CFRequestException carrying the server code, statusCode and droppedCount) through onError and errors, logged even with debug off; ContentFlow.invalidKeyError holds it. While refused, sync() returns the cache, track() drops events, connectLiveUpdates() and setChannelConsent() throw it, and other throwing calls fail at once with a DioException whose error is it. Create a new client with a valid key, or restart the app, to resume. Previously a failed direct-mode event batch went back on an unbounded queue after any failure, 401 included, and was resent forever. Other 403s (DEVICE_NOT_IDENTIFIED, USER_MISMATCH, location refusals) don't trigger it. Write-key calls never do.
- Retries are limited to failures worth retrying. Event batches are retried only after no response, a 429 or a 5xx, with exponential backoff and jitter (about 1 s doubling to at most 5 minutes); a
Retry-After (seconds or HTTP date, capped at 1 hour) is the minimum wait, in the offline queue too. Any other 4xx drops the batch. A new event or flush() no longer cuts a backoff short.
- Bounded queues. The in-memory event queue and the offline queue hold at most 500 events (the offline queue held 100) and drop the oldest past that. Dropped events (queue full, refused, or out of offline retries) are reported as a new
CFDroppedEventsException with counts by reason, at most once a minute. CFOfflineQueue takes an optional onDrop callback and has deferNextRetry(Duration). submitLocationBatch now sends at most 100 points per POST /location, oldest first, chunk after chunk (it used to send only the first 100 and silently discard the rest); a refused chunk is dropped and counted while the next is sent, a 429/5xx/no response stops the call, and a size refusal (TOO_MANY_LOCATIONS, PAYLOAD_TOO_LARGE) is logged as an error and the chunk resent in halves. CFLocationTrackingConfig.batchSize is clamped to 100.
- Live updates reconnect. A stream that drops, or a connect that gets no response, a 429 or a 5xx, is reconnected with the same backoff (honouring
Retry-After) until disconnectLiveUpdates(). A refused key stops it; another 4xx is not retried.
deleteIdentity() keeps the device id after a partial erasure (#968). It treated any 2xx as a completed erasure: on a 207 (some parts erased, success: false, localWipeSafe: false) it cleared local state, replaced the device id and logged "Identity deleted", though the server still held data and the old device id was the only key that could reach it. It now wipes local state and starts over with a new device id only when the response says success: true and localWipeSafe: true. Any other 2xx throws CFRequestException (the 207's status and message) and changes nothing locally, so calling deleteIdentity() again finishes the erasure. A non-2xx still throws DioException, as before.
Added
- On-device geofences (#989). New
startGeofencing(monitor, {options}) / stopGeofencing({monitor}) (in a launch that never started geofencing, pass the monitor and stop still sweeps the SDK regions an earlier launch left; a closed gate at start sweeps them too), isGeofencing, geofenceRegions and handleGeofenceTransition. The SDK fetches the workspace's fences (GET /geofences, ETag, nearest-first), registers the nearest that fit the OS limit (20 on iOS, shared with the app's own regions; 100 on Android) plus a boundary region that triggers re-selection as the device moves, and reports transitions to POST /location with source: "device". Every selected fence is registered and reported for both enter and exit whatever its triggers (the server needs the exit to close a stay; triggers decide what fires server-side), and dwell only when its trigger is on. The fetch sends only lat/lng (together, rounded to 3 decimals) and max (the free slots); truncated: true keeps one slot for the boundary region, truncated: false registers everything returned, and a server without truncated is handled by asking for one more than fits; a 502 or 503 keeps the registered regions, a 200 with no fences unregisters them. Events carry the OS transition time; a dwell computed on the device carries min(enteredAt + dwellMs, now) and is skipped when more than 24 hours old. Location consent relies on GET /geofences returning no fences without it, plus local revocation. Polygon fences register as the bounding circle the server sends. Dwell uses the OS where the plugin supports it, otherwise it is computed from the enter time at the next wake. Opt-in only: it runs while the app has started it, analytics consent is granted, the workspace's location switch is on and location consent is not revoked; otherwise every SDK region is unregistered. A refused report is dropped, a transport failure is kept for the next send. The Flutter SDK ships no native code, so the OS calls go through a CFGeofenceMonitor the app implements with its geofencing plugin (see the README). Until the server route ships the fetch answers 404 and geofencing stays idle. CFDeviceFence has no name (the server no longer sends one; an older server's name key is ignored). Regions left by an earlier process are removed once per process before the first registration and on every teardown (CFGeofenceMonitor.clearSdkRegions plus every cf.geo. id monitoredRegionIds lists). Events of app-defined fences (registerGeofence) whose id the dashboard doesn't have no longer reach analytics.
- New
ContentFlow.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 baseUrl, like registerStrings) and returns CFStringDisableResult (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 ArgumentError before sending anything otherwise; request failures throw DioException, like registerStrings. Write key only: call it from a build script or CI, never from a shipped app. Needs the management API's /strings/disable route.
- Workspace feature switches (#907). The SDK reads
data.config.features from /sync and honours the switches a platform operator sets for the workspace: with live_updates off it opens no live-updates stream (and closes an open one), with campaigns off getCampaigns() returns an empty list without a request, and with location off it pauses tracking, drops unsent points and sends no location batches or geofence events. Each feature resumes when its switch turns back on, but only if the app had started it. Missing or invalid switches count as on, and a 304 keeps the last value (stored with the ETag, so it survives a relaunch). New SdkFeatures, ContentFlow.features and ContentFlow.onFeaturesChange; CFLocationManager.stopTracking takes an optional discardPending. Switches never change the base URL, keys, authentication, certificate pinning or consent.
Changed
- Requests now send
X-CF-SDK-Capabilities: erasure-partial-v1. A gateway answers a partial erasure with the 207 only to SDKs that declare it, and with the earlier 502 to SDKs that don't (Flutter 2.2.2 and earlier). 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 /profile/industry, which the gateway never had, so it always returned null. Industry data is write-only from the SDK, and ContentFlow uses it server-side. Remove calls to it; it will be removed in the next major version (#932). (updateIndustryProfile still calls a route the gateway does not expose; unchanged here.)
2.2.2 — 2026-10-01
Action needed
- Upgrade from 2.2.1:
sync() returned no blocks from the live gateway and setChannelConsent was refused (see Fixed).
- Call
identify before setChannelConsent. Without it, setChannelConsent now throws a StateError and sends nothing.
Fixed
sync() returns blocks from the live gateway (#892). CFBlock.fromJson read id and cast fields to a map, but the gateway sends {key, name, screen, instanceId, segment, version, values, fields} with fields as a schema list, so parsing threw and sync() returned the empty cache. Blocks now read instanceId into id, content from values keyed by tag, and fields into schema.
- Sync failures are visible. A payload without a
blocks array, or a block without a key, is reported as a CFSyncException through the new ContentFlowConfig.onError and ContentFlow.errors, and logged even with debug off. sync() still returns the cache. The ETag of a payload that wasn't applied is no longer stored, so the next sync doesn't get a 304 for content it never had. One malformed block no longer hides the others.
setChannelConsent is accepted by the gateway (#893). It sent the flags at the top level of the body with an inApp key, which the gateway refuses with 400 CHANNELS_REQUIRED. It now sends {deviceId, username, channels: {push, sms, whatsapp, email, locationTracking}}, plus marketing when set. inApp isn't sent (requestConsent still carries it).
setChannelConsent requires identify first. The gateway records consent only for the user this device is identified as. The SDK throws a StateError before sending when it isn't, and throws CFRequestException (with the gateway code, such as DEVICE_NOT_IDENTIFIED or USER_MISMATCH) when the server refuses or doesn't confirm the write, including a 207 success: false.
fetchBlock returns null for a block the server reports as visible: false.
Added
CFBlock: values, schema (CFFieldSchema), instanceId, screen, segment, version, get(tag), typeOf(tag), getCollection(tag), body, ctaLabel, ctaVisible, enabled, CFBlock.tryParse. Accessors use the JS SDK's tag conventions.
parseSyncBlocks, CFSyncException, CFRequestException, ContentFlowConfig.onError, ContentFlow.errors.
CFChannelConsent.marketing and toConsentChannels().
- Every request, the live-updates stream included, sends
X-CF-SDK-Version: flutter/2.2.2 and an empty X-CF-SDK-Capabilities (#903). The SDK declares no capabilities, so it never receives sponsored content. New kContentFlowSdkVersion, kept equal to pubspec.yaml by a test.
Changed
CFBlock.fields is deprecated and returns values. The CFBlock constructor takes values (and optional schema); the old fields: argument still compiles, is deprecated, and fills values. type is now optional. No source change is needed.
CFBlock.cta is built from #cta_label and #cta_url / #cta_action instead of a cta object.
Docs
- The
example/ app runs as shipped once its platform folders exist (flutter create --platforms=android,ios .). It embeds no key: pass CF_PUBLIC_KEY and CF_TENANT_ID with --dart-define, and without them it shows a setup screen and makes no network calls. Analytics consent starts off and is asked for, impressions are tracked once per card, and blocks render from the delivered values (#897).
- Doc comments and the example say where keys live in the portal (Developers → Your keys) and use
<workspace-id>_test placeholders instead of key-shaped values.
2.2.1 — 2026-09-30
Action needed
- If you enable certificate pinning and don't set
baseUrl: the default host changed from app.contentflow.click to api.contentflow.click, which has a different key. Add the api-host pins or set baseUrl explicitly before upgrading. Current pins: app.contentflow.click sha256/A3O5BGFGDQZm4wNh1QpbA4QYeI0vT6D5tCSOy65IrhI=, api.contentflow.click sha256/uYvQE/yGyakA32XBHLaqAjnLJHS82yNLfoh4CRV6lX8= (both certificates valid until 2026-12-11).
- A base URL that doesn't end in
/sdk/v1 now prints one notice per client, even with debug off (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
- The default
baseUrl is https://api.contentflow.click/sdk/v1 (was https://app.contentflow.click/api/v1/sdk).
Added
--dart-define=CONTENTFLOW_BASE_URL=… configures the base URL without code. Precedence: an explicit baseUrl > the dart-define > the default. The value must be https (http only for localhost, 127.0.0.1, 10.0.2.2); an invalid value is ignored with a logged error. New constants kContentFlowDefaultBaseUrl, kContentFlowConfiguredBaseUrl.
2.2.0 — 2026-09-26
Breaking / action needed
- Sign-up and sign-in failures now throw (they were queued to a route that rejects them and silently lost).
- String-sensor calls send only for keys the loaded strings catalog marks as sensors, and only with analytics consent.
- Location batches and geofence events are discarded without analytics consent.
Fixed (these calls reached routes the gateway does not have, or sent payloads it ignores)
- String sensors:
trackStringImpression / trackStringInteraction now send string_impression / string_interaction events through /events (they posted to /strings/impression and /strings/interaction, which do not exist). Same rules as the JS and Swift SDKs: only keys the loaded catalog marks as sensors, analytics consent required, impressions sent once per source and key per session. New optional path.
getStrings returns the strings map. It returned the response envelope (sourceCode, strings, smartKeys as text) instead.
- Live updates connect to
GET /stream (was /live) and react to the server's block.* change signals by re-syncing and emitting the blocks that changed. Previously nothing was ever emitted.
- Campaigns:
getCampaigns sends POST /campaigns with the evaluation request (placement, device, user, segments, language, platform), as the gateway requires (it sent GET).
- Identity events: sign-up and sign-in go to
POST /track-event after identifying the device as the user, whether sent with trackIdentity, track(CFEvent.identity(...)), or the new trackSignUp / trackSignIn. They were queued to /events, which rejects them, and failures were not reported; now they throw.
- Location:
submitLocationBatch and reportGeofenceEvent post to POST /location with locations[] / geofenceEvents[] (were /location/batch and /geofence-event). A batch the gateway could not queue now returns false.
- Logout clears cached blocks (memory, persisted copy and ETag), so a failed re-sync can't keep showing the previous user's targeted content.
- Location consent:
submitLocationBatch and reportGeofenceEvent send nothing without analytics consent.
setLocale now switches the locale. It ignored its argument; blocks, strings and campaigns now send ?locale= and Accept-Language.
Added
logout(): detach the device from its user (POST /logout) and clear local identity.
deleteIdentity(): GDPR erasure (DELETE /identify); clears local state and starts over with a new device id.
unregisterPush(): remove this device's push token (DELETE /register-push).
getLocales(): the workspace's locale catalog (CFLocaleCatalog, CFLocale).
trackSignUp() / trackSignIn().
registerStrings(): register translation keys and source text with a write key; server-side only (POST /api/v1/strings/sync). CFStringRegistration, CFStringRegistrationResult.
locale getter.
Still not available (blocked on a backend decision)
submitKYC, getKYCState, updateIndustryProfile, getIndustryProfile call routes the SDK gateway does not expose, and still fail.
2.1.0 — 2026-09-24
Breaking / action needed
- New minimums: Dart 3.5+, Flutter 3.16+,
connectivity_plus ^7, geolocator ^14. An app pinned to older majors of those packages must upgrade them.
- With certificate pinning enabled, fewer than 2 pins or a malformed pin now throws at init instead of silently turning pinning off, and
applyToDio throws on a non-dart:io adapter (web) instead of running unpinned. The callback getter is removed.
Fixed
- The SDK compiles, analyzes clean and its tests pass. The 2.0.1 Flutter archive did not compile.
example/ is a runnable app.
Security
- Certificate pinning rewritten, still off by default:
- Pins are
sha256/<base64> hashes of the server's public key (SPKI), so they survive certificate renewal. Hex certificate fingerprints still parse but are deprecated.
- The platform's normal chain and hostname validation always applies. The pin is checked right after the TLS handshake, before any request bytes are sent (previously it ran after the request had already gone out).
- At least 2 distinct pins are required (current + backup); fail closed.
- The live-updates connection is pinned too. While pinning is on, proxies are bypassed and plain
http:// is refused.
- New:
CFCertificatePinningException, spkiPin(), createHttpClient(), minimumPins.
- 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.