diff --git a/documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md b/documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md index 3f43b08e4..c8cfc5df1 100644 --- a/documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md +++ b/documentation/guides/integrating-the-optimization-android-sdk-in-a-compose-app.md @@ -79,15 +79,12 @@ explains the two axes and the split form that sets them separately. } ``` - The SDK declares `com.squareup.okhttp3:okhttp-android:5.x` as a runtime dependency directly, - because `contentful.java` 5.x pulls in okhttp 5.x's KMP metadata parent (`com.squareup.okhttp3:okhttp`) - whose `okhttp-jvm` variant is excluded on Android; without an Android runtime variant, the app - throws `ClassNotFoundException: okhttp3.OkHttpClient` at launch. If your app declares - `com.contentful.java:java-sdk` directly (or any other dependency that pulls the same KMP parent), - exclude `com.squareup.okhttp3:okhttp-jvm` from it and align all okhttp declarations on 5.x so the - two variants do not coexist and cause duplicate-class packaging failures. + If you see `ClassNotFoundException: okhttp3.OkHttpClient` at launch, see + [Troubleshooting](#troubleshooting). - **Adapt this to your use case:** + **Only if your app also depends on `com.contentful.java:java-sdk`:** exclude + `com.squareup.okhttp3:okhttp-jvm` from it so its transitive okhttp variant does not conflict with + the SDK's own okhttp dependency. ```kotlin dependencies { @@ -100,7 +97,14 @@ explains the two axes and the split form that sets them separately. 2. Wrap your app UI in `OptimizationRoot`, pass your Optimization client ID, set `logLevel = OptimizationLogLevel.debug` so the SDK logs its activity, and add `ScreenTrackingEffect` - to one screen you already render. + to one screen you already render. `debug` is verbose and not the SDK's default — dial it back + before shipping to production; see the default `logLevel` in + [Install and initialize `OptimizationRoot`](#install-and-initialize-optimizationroot). + + The `MainActivity` and `HomeScreen` scaffolding below is illustrative context to match against your + own app, not a file to paste over yours. Wrap your existing `setContent { }` tree in + `OptimizationRoot` and add `ScreenTrackingEffect` to a screen you already render — keep the rest of + your composables as they are. **Adapt this to your use case:** @@ -144,11 +148,6 @@ explains the two axes and the split form that sets them separately. } ``` - The `MainActivity` and `HomeScreen` scaffolding above is illustrative context to match against your - own app, not a file to paste over yours. Wrap your existing `setContent { }` tree in - `OptimizationRoot` and add `ScreenTrackingEffect` to a screen you already render — keep the rest of - your composables as they are. - 3. Verify the first run. Launch the app on a device or emulator. Because `logLevel = OptimizationLogLevel.debug` is set, the SDK logs its activity to logcat under the `ContentfulOptimization` tag. `ScreenTrackingEffect` sends the screen event through @@ -326,29 +325,20 @@ For package status and installation details, see **Integration category:** Common but policy-dependent -Consent policy stays application-owned. Consent has two independent axes: **event consent** (may the -SDK personalize and emit events) and **persistence consent** (may the SDK store profile continuity in -`SharedPreferences`). The boolean call `client.consent(accept)` sets both at once; the split call -`client.consent(events, persistence)` sets them independently. `StorageDefaults(consent = true)` seeds -accepted event and persistence consent at startup — use it only when application policy permits -Optimization by default and you render no consent UI. +Consent policy stays application-owned. Make one decision first, then wire the SDK call for it: -`StorageDefaults` values are startup defaults, not one-time seeds: a configured value takes precedence -over the stored `SharedPreferences` value every launch, so a configured `consent` can replace a stored -choice. Apps that persist a user's own decision leave `StorageDefaults.consent` unset and call -`client.consent(...)` from resolved app policy instead. +1. **Does your application policy permit Optimization to start with accepted consent, with no + consent UI shown?** If yes, seed `StorageDefaults(consent = true)` and skip to + [Contentful entry fetching and locale shape](#contentful-entry-fetching-and-locale-shape) — this is + the quick start's default. +2. **Otherwise, wait for the visitor's consent choice.** Leave `StorageDefaults.consent` unset and + call `client.consent(true)` after the visitor accepts, `client.consent(false)` after they reject. -`OptimizationConfig.defaults` is snapshotted when `initialize(config)` runs. It is a mutable -property on the config object, so you can assemble it conditionally before passing the config in, -but reassigning `config.defaults` after initialization has no effect — use `client.consent(...)` for -runtime consent changes. +Once you know which path applies, two more choices refine it: -1. Seed accepted consent with `StorageDefaults(consent = true)` only when policy permits default-on - Optimization and no consent UI is shown. -2. Otherwise leave consent unset and call `client.consent(true)` after the visitor accepts, - `client.consent(false)` after they reject. -3. Use the split form when events are allowed but durable profile continuity must stay session-only. -4. Read `client.state` when consent UI must reflect SDK state across app launches. +3. Use the split call `client.consent(events, persistence)` instead of the boolean `client.consent(accept)` + when events are allowed but durable profile continuity must stay session-only. +4. Read `client.state` from Compose when consent UI must reflect SDK state across app launches. **Adapt this to your use case:** @@ -379,7 +369,28 @@ client.consent(events = true, persistence = false) The split form above keeps the profile, its selected optimizations — the per-experience variant selections the Experience API returned for this visitor — and `changes` — the inline field and flag -values it returned — in memory only, so nothing is written to `SharedPreferences`. Before event consent is accepted, the native default allow-list lets `identify` +values it returned — in memory only, so nothing is written to `SharedPreferences`. + +**Reference: consent axes, defaults, and the allow-list** + +Consent has two independent axes: **event consent** (may the SDK personalize and emit events) and +**persistence consent** (may the SDK store profile continuity in `SharedPreferences`). The boolean +call `client.consent(accept)` sets both at once; the split call `client.consent(events, persistence)` +sets them independently. + +`StorageDefaults` values are startup defaults, not one-time seeds: a configured value takes precedence +over the stored `SharedPreferences` value every launch, so a configured `consent` can replace a stored +choice. Apps that persist a user's own decision leave `StorageDefaults.consent` unset and call +`client.consent(...)` from resolved app policy instead. + +`OptimizationConfig.defaults` is snapshotted when `initialize(config)` runs. It is a mutable +property on the config object, so you can assemble it conditionally before passing the config in, +but reassigning `config.defaults` after initialization has no effect — use `client.consent(...)` for +runtime consent changes. + +Before event consent is accepted, the native **default allow-list** — the set of event types the SDK +lets through even without consent, listed in full in +[Strict event policy and queue controls](#strict-event-policy-and-queue-controls) — lets `identify` and `screen` events emit; entry-view events (wire type `component`), tap events (`component_click`), and custom `track` events are blocked until consent is accepted or you allow-list them. `client.consent(false)` clears event and persistence consent, purges queued events, and clears durable @@ -437,14 +448,18 @@ suspend fun fetchEntry(entryId: String, locale: String): CDAEntry = withContext( } ``` -Every entry passed to `OptimizedEntry` or `client.resolveOptimizedEntry(...)` as a raw -`Map` must include a top-level `metadata` block (tags and concepts) — the resolver -reads it alongside `sys` and `fields`, and when it is missing the resolver silently returns the -baseline entry with no error. The failure mode is indistinguishable from an entry that has no -experience configured, so mapping bugs surface as "personalization isn't working" with nothing to -trace. If you fetch with `contentful.java`, prefer the `CDAEntry` overload shown in -[Entry resolution and fallback rendering](#entry-resolution-and-fallback-rendering) — its adapter -builds `metadata` for you and removes this failure class entirely. +> [!WARNING] +> +> Every entry passed to `OptimizedEntry` or `client.resolveOptimizedEntry(...)` as a raw +> `Map` must include a top-level `metadata` key that is present and is an object +> (its `tags`/`concepts` contents are not checked). Kotlin itself does not enforce this — the raw map +> is forwarded unmodified to the shared JS-core type guard that requires it; when that check fails, +> resolution silently returns the baseline entry with no thrown error. The failure mode is +> indistinguishable from an entry that has no experience configured, so mapping bugs surface as +> "personalization isn't working" with nothing to trace. If you fetch with `contentful.java`, prefer +> the `CDAEntry` overload shown in +> [Entry resolution and fallback rendering](#entry-resolution-and-fallback-rendering) — its adapter +> builds `metadata` for you and removes this failure class entirely. The SDK Experience/event `locale` is distinct from the Contentful CDA locale: your app chooses the CDA locale for its own fetch, and `OptimizationConfig(locale = ...)` sets the locale the Experience API and @@ -487,11 +502,19 @@ top-level `sys`, `fields`, and `metadata`, and its render lambda receives anothe convert with `CTEntry.from(...)`. `client.resolveOptimizedEntry(baseline, selectedOptimizations)` returns the SDK-owned -`ResolvedOptimizedEntry`. When `isEmptyVariant` is `true`, the SDK renderer uses its no-content -state. That differs from a fallback, which returns the baseline entry for normal rendering. -`OptimizedEntry` reads this field: in the no-content state it keeps its tracking `Box` but does not -invoke its content lambda. A later non-empty result invokes the lambda with the current resolved -entry. An absent or invalid empty-variant field renders normally. +`ResolvedOptimizedEntry`. `isEmptyVariant` and fallback are different outcomes with different UI +effects, not two names for the same thing: + +- **`isEmptyVariant` is `true`** — an experience selected a deliberately empty variant (content + authored to render nothing). `OptimizedEntry` keeps its tracking `Box` in place, holding the + tracking surface open, but does not invoke its content lambda — so nothing renders where the entry + would have been. +- **Fallback** — no variant applies, and `resolveOptimizedEntry` returns the baseline entry. + `OptimizedEntry` invokes its content lambda with that baseline entry, so your app renders the + original content normally. + +A later non-empty result invokes the content lambda with the current resolved entry. An absent or +invalid empty-variant field renders normally (as a fallback would). A selected variant can use any Contentful content type. Branch on `contentTypeId`, check `hasField(...)`, and then read the matching field with `getField(...)`. A different content type is @@ -653,10 +676,18 @@ blocked until event consent (or an allow-list entry) permits them. View tracking is viewport-based. Wrap scrollable content in `OptimizationLazyColumn` so view timing uses the real scroll position; without an enclosing scroll context, tracking assumes `scrollY` is `0` and uses the system display height as the viewport, which suits only non-scrolling or already-visible -layouts. The default view threshold is 80% visibility (`minVisibleRatio` `0.8`) held for 2000 ms -(`dwellTimeMs`); after the first view event, duration updates emit every 5000 ms -(`viewDurationUpdateIntervalMs`) while the entry stays visible, and a final duration update emits when -the entry leaves the viewport once at least one view event has fired. +layouts. + +Default viewport-tracking parameters: + +| Parameter | Default | Meaning | +| ------------------------------ | ------- | ------------------------------------------------------------------------------------------- | +| `minVisibleRatio` | `0.8` | Fraction of the entry that must be visible to count as viewed. | +| `dwellTimeMs` | `2000` | How long that visibility must hold before the first view event fires. | +| `viewDurationUpdateIntervalMs` | `5000` | How often a duration update emits while the entry stays visible after the first view event. | + +A final duration update emits when the entry leaves the viewport, once at least one view event has +fired. A tap uses Compose's `clickable {}` on the `OptimizedEntry` wrapper: it emits the `component_click` event, then calls the optional `onTap` lambda. That lambda receives the **baseline** entry you passed @@ -854,11 +885,14 @@ For cross-SDK forwarding patterns, see Custom Flags and merge tags read profile-backed values the Experience API returns, separately from entry variant selection. `client.getFlag(name)` is a one-time, non-reactive JSON read that returns `null` before initialization; `client.observeFlag(name)` returns a `StateFlow` (the Android -idiom — the iOS SDK uses a Combine publisher) that updates as the flag value changes. Both entry -points emit a `component` flag-view event through the event stream when consent and profile allow — -`getFlag` fires once per call, `observeFlag` fires each time a delivered value changes — so every -flag read is a tracked analytics exposure, not only subscriptions. Apply the same governance you use -for other SDK events. +idiom — the iOS SDK uses a Combine publisher) that updates as the flag value changes. + +> [!WARNING] +> +> Both entry points emit a `component` flag-view event through the event stream when consent and +> profile allow — `getFlag` fires once per call, `observeFlag` fires each time a delivered value +> changes — so every flag read is a tracked analytics exposure, not only subscriptions. Apply the +> same governance you use for other SDK events. `client.getMergeTagValue(mergeTagEntry)` is a suspend call that resolves an inline `nt_mergetag` entry — the SDK-owned merge-tag content-model identifier — against the current profile and returns the @@ -910,6 +944,10 @@ By default, `OptimizedEntry` locks to the first variant it resolves so content d visitor is reading it. Enable live updates when a screen needs mounted entries to react to profile changes or preview overrides without a reload. +The settings below apply in this precedence order: an open preview panel forces live updates +(overriding an explicit `liveUpdates = false`), then a per-entry `liveUpdates` value, then the +`OptimizationRoot` `liveUpdates` default, then the locked default. + 1. Set `liveUpdates = true` on `OptimizationRoot` when most mounted entries in the tree must update as SDK state changes. 2. Set `liveUpdates = true` on an individual `OptimizedEntry` for a localized live section. @@ -938,10 +976,8 @@ OptimizedEntry( } ``` -The resolution order is: an open preview panel forces live updates (overriding an explicit -`liveUpdates = false`), then a per-entry `liveUpdates` value, then the `OptimizationRoot` `liveUpdates` -default, then the locked default. When the preview panel closes, a locked `OptimizedEntry` snapshots -the current selections so applied overrides persist. For the precedence rules, see +When the preview panel closes, a locked `OptimizedEntry` snapshots the current selections so applied +overrides persist. For the full precedence rules, see [Android SDK runtime and interaction mechanics](../concepts/android-sdk-runtime-and-interaction-mechanics.md#live-updates-and-preview-behavior). ### Preview panel @@ -1139,13 +1175,14 @@ pnpm implementation:run -- android-sdk test:e2e:compose -- --flow ## Troubleshooting -| Symptom | Likely cause | Check | -| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `No OptimizationClient provided` | A composable called SDK helpers outside `OptimizationRoot`. | Move the composable under the `OptimizationRoot` that owns the SDK client. | -| Entries always render baseline content | The entry is not optimized, selected optimizations are missing, links are unresolved, or the CDA payload is all-locale. | Verify consent, a `screen` or `identify` event, `include` depth, a concrete `locale`, and `fields.nt_experiences`. | -| Entry view or tap events are missing | Tracking was opted out, consent does not permit `trackView`/`trackClick`, or the list lacks a scroll context. | Confirm `trackViews`/`trackTaps`, consent, the dwell threshold, and `OptimizationLazyColumn` for lists. | -| Screen events duplicate or go missing | `ScreenTrackingEffect` is placed in repeated child composables, or more than one path tracks the route. | Place the effect at the route or destination root, keep names stable, and use one screen-tracking path. | -| Preview panel shows identifiers only | No `PreviewContentfulClient` was passed, so the panel cannot fetch definitions. | Pass a `PreviewContentfulClient` so the panel fetches `nt_audience`/`nt_experience` and shows names. | +| Symptom | Likely cause | Check | +| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ClassNotFoundException: okhttp3.OkHttpClient` at launch | `contentful.java` 5.x pulls in okhttp 5.x's KMP metadata parent (`com.squareup.okhttp3:okhttp`), whose `okhttp-jvm` variant is excluded on Android, leaving no Android runtime variant. | Confirm the SDK's direct `com.squareup.okhttp3:okhttp-android:5.x` dependency is present, and that any other dependency pulling the same KMP parent (for example `com.contentful.java:java-sdk`) excludes `com.squareup.okhttp3:okhttp-jvm` and aligns on okhttp 5.x so the two variants do not coexist and cause duplicate-class packaging failures. | +| `No OptimizationClient provided` | A composable called SDK helpers outside `OptimizationRoot`. | Move the composable under the `OptimizationRoot` that owns the SDK client. | +| Entries always render baseline content | The entry is not optimized, selected optimizations are missing, links are unresolved, or the CDA payload is all-locale. | Verify consent, a `screen` or `identify` event, `include` depth, a concrete `locale`, and `fields.nt_experiences`. | +| Entry view or tap events are missing | Tracking was opted out, consent does not permit `trackView`/`trackClick`, or the list lacks a scroll context. | Confirm `trackViews`/`trackTaps`, consent, the dwell threshold, and `OptimizationLazyColumn` for lists. | +| Screen events duplicate or go missing | `ScreenTrackingEffect` is placed in repeated child composables, or more than one path tracks the route. | Place the effect at the route or destination root, keep names stable, and use one screen-tracking path. | +| Preview panel shows identifiers only | No `PreviewContentfulClient` was passed, so the panel cannot fetch definitions. | Pass a `PreviewContentfulClient` so the panel fetches `nt_audience`/`nt_experience` and shows names. | ## Reference implementations to compare against diff --git a/documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md b/documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md index ecf286839..6cbc2292b 100644 --- a/documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md +++ b/documentation/guides/integrating-the-optimization-android-sdk-in-a-views-app.md @@ -52,11 +52,14 @@ SDK initializes from your application and one screen event is accepted, and a vi confirm it.** It initializes one manager in `Application.onCreate`, registers the subclass in the manifest, and tracks the current screen from an activity's `onResume`. -This quick start assumes your application policy permits Optimization to start with accepted consent -and renders no end-user consent UI, so it seeds `StorageDefaults(consent = true)` — the shorthand -that accepts both consent axes at once. If personalization must wait for a consent decision, keep this -structure and add the [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) step -before you ship; it explains the two axes and the split form that sets them separately. +> [!WARNING] +> +> This quick start assumes your application policy permits Optimization to start with accepted +> consent and renders no end-user consent UI, so it seeds `StorageDefaults(consent = true)` — the +> shorthand that accepts both consent axes at once. If personalization must wait for a consent +> decision, keep this structure and add the +> [Consent and privacy-policy handoff](#consent-and-privacy-policy-handoff) step before you ship; it +> explains the two axes and the split form that sets them separately. 1. Add the SDK dependency to your application module from Maven Central. @@ -73,12 +76,15 @@ before you ship; it explains the two axes and the split form that sets them sepa ``` The SDK declares `com.squareup.okhttp3:okhttp-android:5.x` as a runtime dependency directly, - because `contentful.java` 5.x pulls in okhttp 5.x's KMP metadata parent (`com.squareup.okhttp3:okhttp`) - whose `okhttp-jvm` variant is excluded on Android; without an Android runtime variant, the app - throws `ClassNotFoundException: okhttp3.OkHttpClient` at launch. If your app declares + because `contentful.java` 5.x pulls in okhttp 5.x's KMP metadata parent (`com.squareup.okhttp3:okhttp` + — a placeholder artifact Gradle resolves instead of a real implementation) whose `okhttp-jvm` + variant is excluded on Android; without an Android runtime variant, the app throws + `ClassNotFoundException: okhttp3.OkHttpClient` at launch. If your app declares `com.contentful.java:java-sdk` directly (or any other dependency that pulls the same KMP parent), exclude `com.squareup.okhttp3:okhttp-jvm` from it and align all okhttp declarations on 5.x so the - two variants do not coexist and cause duplicate-class packaging failures. + two variants do not coexist and cause duplicate-class packaging failures. See + [Troubleshooting](#troubleshooting) if you still see `ClassNotFoundException: okhttp3.OkHttpClient` + after adding the dependency. **Adapt this to your use case:** @@ -95,6 +101,15 @@ before you ship; it explains the two axes and the split form that sets them sepa normal (non-suspend) call that constructs the process-wide client and starts it in the background; activities read `OptimizationManager.client` afterward. + The unchanged lines in the diff below are illustrative context to match against your own + `Application` subclass, not a block to paste over it. If your app has no `Application` subclass + yet, the whole file is new. `StorageDefaults` is an SDK config type that carries the SDK's startup + state, including the two consent axes; `StorageDefaults(consent = true)` grants both at launch. + `OptimizationLogLevel.debug` is verbose and not the SDK default (`error`); it is used here so you + can see the SDK's activity in logcat while verifying the quick start, but dial it back for + production (see step 4 below and + [Production checks](#production-checks)). + **Adapt this to your use case:** ```diff @@ -117,6 +132,7 @@ before you ship; it explains the two axes and the split form that sets them sepa + // environment defaults to "main"; pass it only when your setup differs. + locale = "en-US", + defaults = StorageDefaults(consent = true), + + // debug is verbose and non-default; useful here to see activity in logcat. + logLevel = OptimizationLogLevel.debug, + ), + ) @@ -124,11 +140,6 @@ before you ship; it explains the two axes and the split form that sets them sepa } ``` - The unchanged lines above are illustrative context to match against your own `Application` - subclass, not a block to paste over it. If your app has no `Application` subclass yet, the whole - file is new. `StorageDefaults` is an SDK config type that carries the SDK's startup state, - including the two consent axes; `StorageDefaults(consent = true)` grants both at launch. - Then register the subclass in `AndroidManifest.xml` with `android:name`. Without it, `Application.onCreate` never runs and the SDK never initializes. @@ -145,6 +156,20 @@ before you ship; it explains the two axes and the split form that sets them sepa is illustrative app shape — adapt it to a screen you already render, keeping the two stream subscriptions and the `ScreenTracker.trackScreen` call in `onResume`. + Add a `TextView` with the id `optimization_status` to that screen's layout (for example + `activity_home.xml`) before wiring the code below — the `findViewById(R.id.optimization_status)` + call requires it to already exist in the layout. + + **Copy this:** + + ```xml + + ``` + **Adapt this to your use case:** ```diff @@ -262,9 +287,11 @@ outside this guide: cannot yet distinguish working personalization from a content-authoring gap. For the first personalized-content test, target all visitors so the test request or visitor matches automatically. - **Your Optimization project values** — client ID and environment, from your Optimization project - settings. In the Contentful web app, the path depends on which navigation your organization uses: - in **classic navigation**, go to **Apps → Installed apps → Contentful Personalization → SDK keys**; - in **new navigation** (the Contentful app with ExO navigation enabled), go to + settings. In the Contentful web app, the path depends on which navigation your organization uses; + check your left-hand sidebar to tell which one applies: if it shows a top-level **Apps** entry, you + are on classic navigation; if it shows a top-level **Platform** entry above **Apps**, you are on new + navigation (the Contentful app with ExO navigation enabled). In **classic navigation**, go to + **Apps → Installed apps → Contentful Personalization → SDK keys**; in **new navigation**, go to **Platform/Apps → Installed apps → Contentful Personalization → SDK keys**. The Client ID and environment are listed there. @@ -459,9 +486,19 @@ two independent axes: event consent (may the SDK personalize and emit events) an ``` 3. Use the split form when event emission is allowed but durable profile continuity must stay - session-only. `consent(events = false)` withdraws event consent and purges queues but leaves - persistence unless you also pass `persistence = false`; `consent(false)` clears both axes, purges - queues, and clears durable continuity while in-memory state stays usable until reset or teardown. + session-only. + + | Axis | Call form | Effect | + | ------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------- | + | Event consent | `consent(true)` or `consent(events = true)` | Allows event emission. | + | Event consent | `consent(false)` or `consent(events = false)` | Withdraws event consent and purges queued events. | + | Persistence consent | `consent(true)` (boolean form) | Allows durable profile continuity in `SharedPreferences`. | + | Persistence consent | `consent(events = false)` (persistence omitted) | Leaves persistence consent unchanged — only the event axis is withdrawn. | + | Persistence consent | `consent(false)` (boolean form) or `persistence = false` | Clears durable continuity; in-memory state stays usable until reset or teardown. | + + The boolean form `consent(accepted)` sets both axes at once; the split form + `consent(events = ..., persistence = ...)` sets them independently, and any axis you omit from the + split form is left unchanged. **Adapt this to your use case:** @@ -533,11 +570,19 @@ content model. 1. Keep Contentful fetching in the application layer. Fetch by entry ID, or adapt the app's existing Contentful query with the slug filters below when navigation supplies a slug. Pass the one fetched entry to native resolution. Do not pass all-locale CDA responses or `locale=*` payloads to - `OptimizedEntryView` — they fall back to baseline. Every entry passed as a raw - `Map` must include a top-level `metadata` block (tags and concepts); if `metadata` is - missing, the resolver silently returns the baseline with no error, indistinguishable from an entry - that has no experience configured. The `setEntry(entry: CDAEntry)` overload below removes this - failure class by building `metadata` for you through the SDK-owned adapter. + `OptimizedEntryView` — they fall back to baseline. + + > [!WARNING] + > + > Every entry passed as a raw `Map` must include a top-level `metadata` block (tags + > and concepts). Kotlin does not validate or reject a missing `metadata` key — the raw map is + > forwarded unmodified to the shared JS core, where the `isResolvedContentfulEntry` type guard + > requires a top-level `metadata` key that is present and is an object (its contents are never + > checked). When that check fails, `OptimizedEntryResolver.resolve` silently returns the baseline + > with no thrown error, indistinguishable from an entry that has no experience configured. The + > `setEntry(entry: CDAEntry)` overload below removes this failure class by always building + > `metadata` for you through the SDK-owned adapter. + For a slug route, reuse the Contentful client and fetcher your app already owns. Send `content_type=page` and `fields.slug=` as exact-equality filters, plus one concrete `locale`, enough `include` depth, and `limit=2`. Return the entry only for exactly one CDA item; @@ -752,13 +797,20 @@ whether these events are allowed by its Analytics and privacy policy. ) ``` -2. Override tracking per entry when a component needs different behavior from the global default. A - non-null `onTap` keeps the entry tappable even when global `trackTaps` is on; setting per-entry - `trackViews = false` or `trackTaps = false` opts that one entry out. `onTap` runs through the same - tap path as SDK tap tracking, so `trackTaps = false` disables both the `component_click` event and - `onTap` — do not combine them. When a component needs an app-owned tap handler without SDK tap - analytics, set `trackTaps = false` and attach a normal Android click listener inside the child view - your renderer returns instead. +2. Override tracking per entry when a component needs different behavior from the global default. + `onTap` and `trackTaps` run through the same tap path, so their combination determines the + resulting behavior: + + | `trackTaps` (per entry) | `onTap` | Resulting behavior | + | ----------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | + | `true` (default) | `null` | SDK emits `component_click`; no app-owned tap handler. | + | `true` (default) | non-null | SDK emits `component_click`, then calls `onTap` — the entry stays tappable even when global `trackTaps` is on. | + | `false` | `null` or non-null | Neither `component_click` nor `onTap` fires — `trackTaps = false` disables both, since `onTap` runs through the same tap path as SDK tap tracking. | + + Setting per-entry `trackViews = false` opts that one entry out of view tracking independently. + When a component needs an app-owned tap handler without SDK tap analytics, set `trackTaps = false` + and attach a normal Android click listener inside the child view your renderer returns instead of + using `onTap`. **Adapt this to your use case:** @@ -924,9 +976,13 @@ when it includes profile-driven text substitutions in Rich Text. Both read from from entry-variant resolution, and they wait for initialization before returning real values. 1. Read a flag once with `getFlag(name)` when a synchronous value is enough (returns `null` before - init or when unresolved). Each `getFlag` call emits a `component` flag-view event when consent - and profile allow, so every flag read is a tracked analytics exposure — not only observed - subscriptions. Apply the same governance you use for other SDK events. + init or when unresolved). + + > [!WARNING] + > + > Each `getFlag` call emits a `component` flag-view event when consent and profile allow, so every + > flag read is a tracked analytics exposure — not only observed subscriptions. Apply the same + > governance you use for other SDK events. **Adapt this to your use case:** @@ -941,9 +997,12 @@ from entry-variant resolution, and they wait for initialization before returning 2. Observe with `observeFlag(name)` when a view must update as flag values change. It returns a `StateFlow` (the Android reactive idiom, where iOS uses a Combine publisher). - Subscribing to a flag observable emits a `component` flag-view event when consent and profile - allow, so treat a flag subscription as a tracked analytics exposure, not a free read, and govern it - like any other event. + + > [!WARNING] + > + > Subscribing to a flag observable emits a `component` flag-view event when consent and profile + > allow, so treat a flag subscription as a tracked analytics exposure, not a free read, and govern + > it like any other event. **Adapt this to your use case:** @@ -1141,11 +1200,15 @@ Before releasing an Android Views integration, verify these checks: The repository's maintainers validate Views behavior with Maestro flows driven from `implementations/android-sdk/`; those runners are maintainer commands, not app commands. - **Confirm in Live Events** — In addition to local log and status checks, open the target Contentful - space and environment's Live Events view in the Contentful web app, trigger a real flow from the app - (a screen view, an entry view or tap, an `identify()` call, or a custom `track()` call), and confirm - the corresponding event arrives with the expected wire type (`identify`, `screen`, `component`, - `component_click`, or `track`) and payload fields (for example `userId`/`traits` for `identify`, - `name`/`routeKey` for `screen`, `event`/`properties` for `track`). + space and environment's Live Events view in the Contentful web app — check your left-hand sidebar to + tell which navigation applies (a top-level **Apps** entry means classic navigation; a top-level + **Platform** entry above **Apps** means new navigation): in **classic navigation**, go to + **Apps → Installed apps → Contentful Personalization → Live Events**; in **new navigation**, go to + **Platform/Apps → Installed apps → Contentful Personalization → Live Events**. Trigger a real flow + from the app (a screen view, an entry view or tap, an `identify()` call, or a custom `track()` call), + and confirm the corresponding event arrives with the expected wire type (`identify`, `screen`, + `component`, `component_click`, or `track`) and payload fields (for example `userId`/`traits` for + `identify`, `name`/`routeKey` for `screen`, `event`/`properties` for `track`). **Reference excerpt:** @@ -1156,6 +1219,12 @@ Before releasing an Android Views integration, verify these checks: ## Troubleshooting +- **`ClassNotFoundException: okhttp3.OkHttpClient` at launch** — `contentful.java` 5.x pulls in + okhttp 5.x's KMP metadata parent (`com.squareup.okhttp3:okhttp`), a placeholder artifact with no + `okhttp-jvm` variant on Android. Exclude `com.squareup.okhttp3:okhttp-jvm` from any dependency that + pulls that parent (`com.contentful.java:java-sdk` and similar) and align all okhttp declarations on + 5.x so the excluded `okhttp-jvm` variant and the SDK's direct `okhttp-android:5.x` implementation do + not coexist. - **SDK never initializes or events never emit** — Confirm the `Application` subclass is registered with `android:name` in `AndroidManifest.xml` (without it `onCreate` never runs), and that direct suspend calls await `client.isInitialized.first { it }` before running. diff --git a/documentation/internal/sdk-knowledge/native/android.md b/documentation/internal/sdk-knowledge/native/android.md index d3b599a93..a0ea48db4 100644 --- a/documentation/internal/sdk-knowledge/native/android.md +++ b/documentation/internal/sdk-knowledge/native/android.md @@ -202,6 +202,22 @@ viewportHeight }` via `LocalScrollContext` that descendant `Modifier.trackViews` way, so callers on the CDAEntry overload read the resolved variant through `getField` / `hasField` / `id` instead of `as?` casts on a raw map. Numbers round-trip through JSON as `Double` (Gson's default); read numeric fields as `Double`, not `Int`. source: extern:CTEntry backed by SDK-owned Entry data class, from(CDAEntry)/from(Map, fallback)/from(String, fallback), Gson round-trip, toMap/toJSON, mirrored accessors, CDAEntry walk with ancestor cycle guard — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/contentful/CTEntry.kt#CTEntry +- The raw-map `OptimizedEntry(Map, ...)` / `OptimizedEntryView.setEntry(Map, ...)` / + `resolveOptimizedEntry(Map, ...)` path never validates the map's shape in Kotlin — `CTEntry.from(Map, +fallback)`'s own fail-soft `catch` only fires on a cyclic self-referential map or a Gson + deserialization type mismatch (e.g. `sys` present as a non-object). A map that simply omits a + top-level key parses successfully in Kotlin: `Entry.metadata` is a nullable field, so a missing + `metadata` key just becomes `null` with no exception and no fallback at this layer. The actual gate + lives in the shared JS core the raw map is forwarded to unmodified: `isResolvedContentfulEntry` + requires `isRecord(getRecord(value, 'metadata'))` — a top-level `metadata` key that is present and is + an object (its `tags`/`concepts` contents are never inspected; an empty `{}` satisfies the check) — + alongside `sys.type === 'Entry'`, `sys.id`, and `sys.contentType.sys.id`. `OptimizedEntryResolver.resolve` + calls `isResolvedOptimizedEntry` (which requires `isResolvedContentfulEntry`) first and, on failure, + logs a debug line and returns the baseline entry with no thrown error — a raw map missing the + `metadata` key is therefore indistinguishable, from the resolver's output, from an entry with no + experience configured at all. The `CDAEntry` overload sidesteps this because `CTEntry.Entry.from` + always populates `metadata` from `entry.metadata()` (defaulting to empty tag/concept lists, never + absent). source: extern:CTEntry.from(Map, fallback) catch only covers cyclic maps/Gson type mismatches, Entry.metadata is nullable so a missing key parses cleanly — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/contentful/CTEntry.kt#CTEntry; extern:resolveOptimizedEntry forwards the raw baseline map to the bridge unmodified — packages/android/ContentfulOptimization/src/main/kotlin/com/contentful/optimization/core/OptimizationClient.kt#OptimizationClient; api-schemas#contentful/typeGuards.ts#isResolvedContentfulEntry; api-schemas#contentful/typeGuards.ts#isResolvedOptimizedEntry; core-sdk#resolvers/OptimizedEntryResolver.ts#OptimizedEntryResolver - `OptimizedEntry(CDAEntry, ...)` (Compose) and `OptimizedEntryView.setEntry(CDAEntry, ...)` (Views) are typed overloads on the `Map` public surface: they wrap the live `CDAEntry` in a `CTEntry` at construction (which builds the SDK-owned `Entry` via the walk on `CTEntry.Entry.from`), diff --git a/skills/optimization-guide-authoring/references/authoring-checklist.md b/skills/optimization-guide-authoring/references/authoring-checklist.md index 7469c9c40..53f86a086 100644 --- a/skills/optimization-guide-authoring/references/authoring-checklist.md +++ b/skills/optimization-guide-authoring/references/authoring-checklist.md @@ -102,6 +102,20 @@ add per-archetype checks. TypeScript body snippets) without a prose note stating the switch and how to run the new form. The run command shown for a `**Copy this:**` block must actually work for that block's language. +- [ ] **A silent-degradation behavior gets a callout, not inline prose.** When the SDK fails soft on + misuse (returns baseline, returns `null`, no-ops) instead of throwing or logging, the guide + calls it out with a `[!WARNING]`/`[!NOTE]`-style block, not a sentence buried mid-paragraph — a + reader cannot self-diagnose a failure mode that produces no error to search for, so the trap + must be visually distinct from surrounding reference prose. +- [ ] **Multi-axis boolean or enum semantics are tabulated, not only described in prose.** When a + behavior crosses two or more independent flags/call-forms (e.g. a consent call's `events` vs. + `persistence` axes, or a per-entry override interacting with a global default), a short table + (axis × form × effect) replaces a paragraph that would otherwise ask the reader to derive the + truth table themselves. +- [ ] **A warning about a code block precedes the block, not follows it.** Any note that changes how + the reader should treat the snippet below it (illustrative-only context, paste-over risk, a + compliance-relevant default) appears before the fenced block, not after — a reader acts on the + code top-to-bottom and will not see a warning placed below it until too late. - [ ] `pnpm exec prettier --write ` leaves the file clean (run it; Prettier owns formatting). - [ ] The collapsible TOC preserves the mtoc markers, omits `## Quick start`, and every anchor resolves to a real heading.