Skip to content

feat: setEmbeddedData() + clearEmbeddedData() for app surveys [ENG-2472] - #58

Merged
pandeymangg merged 4 commits into
mainfrom
claude/mobile-sdk-embedded-data-4dwd3r
Aug 31, 2026
Merged

feat: setEmbeddedData() + clearEmbeddedData() for app surveys [ENG-2472]#58
pandeymangg merged 4 commits into
mainfrom
claude/mobile-sdk-embedded-data-4dwd3r

Conversation

@itsjavi

@itsjavi itsjavi commented Aug 28, 2026

Copy link
Copy Markdown
Member

What & why

Was: track() takes a name and nothing else, so the only way to get context onto an app-survey response was to declare a field and have the respondent type it. Now: the host app can attach context to future responses without tying it to a trigger.

Formbricks.setEmbeddedData(mapOf(
    "screen" to EmbeddedDataValue.string("checkout"),
    "plan" to EmbeddedDataValue.string("pro"),
))
Formbricks.setEmbeddedData(mapOf("screen" to null))  // remove one key
Formbricks.clearEmbeddedData("plan")                 // same, explicitly
Formbricks.clearEmbeddedData()                       // everything — logout, context switch

Mirrors js-core's setEmbeddedData key for key (formbricks/formbricks#8989), so web and mobile behave identically.

  • Merge, never replace. null removes a key; a key left out is untouched — that is how a host skips a field it has no value for this screen. The single-key and clear-everything forms are separate overloads, so a non-null String parameter means a host reading the key from its own state cannot accidentally wipe the bag.
  • In-memory, never persisted. Not SharedPreferences — persisting would blur the Embedded Data ↔ contact-attribute boundary and create a PII-at-rest surface. Cleared on an identity switch and on logout, so one user's context cannot ride onto the next user's responses on a shared device; kept on first identification, because a host legitimately pushes context before it knows who the user is.
  • Callable before setup(), unlike every other public method: a host that pushes context at launch must not have the value dropped because initialization had not finished.
  • Snapshot at display, then frozen. Read in loadHtml(), which runs after any configured delay.
  • Dumb pipe. The bag rides the props payload under hiddenFieldsRecord — no new bridge message. It goes out raw: the ingest contract (allow-list, coercion, locked, size caps) lives in the renderer, and the server re-runs all of it.
  • A debug-level success trace, because the bag is otherwise invisible — memory-only, no getter — so a host wiring this up got no confirmation until a survey happened to display. Mirrors js-core (feat(js-core): debug-log the Embedded Data bag's successful writes [ENG-1844] formbricks#9091). Keys only, never values: the documented use of this bag includes hashed identity fields.

Where to look: manager/EmbeddedDataManager.kt (the store, the lock, the trace and every lifetime rule) · Formbricks.kt (the two overloads and identity-switch clearing) · extensions/DateExtensions.kt (the locale fix) · webview/FormbricksViewModel.kt (one key in the payload).

Requires the renderer change in formbricks/formbricks#9067 for the auto-capture half; this PR is independent of it and needs no server change.

Linear ticket

https://linear.app/formbricks/issue/ENG-2472/mobile-sdk-parity-for-embedded-data-one-batched-release-per-sdk

How this was tested

  • ./gradlew :android:compileDebugKotlin ✅ and :android:compileDebugAndroidTestKotlin ✅ (Android SDK 35, JDK 21), re-run after each change. No new warnings.
  • The instrumented suite cannot run in the authoring environment — no emulator, and /dev/kvm is absent. CI's emulator job is the only place these execute. Anything that could be verified without one was verified on the JVM instead; see the folds.
  • Tests: androidTest/.../manager/EmbeddedDataManagerInstrumentedTest.kt (20) covers merge, null removal, omitted-key no-op, last-write-wins, single-key clear, clearing an unset key, clear-all, every scalar with Date→ISO 8601, ASCII ISO 8601 under a native-digit device locale, JSON parseability (including a value containing quotes), the non-finite-number guard, the detached snapshot, works-before-setup, not-persisted-to-SharedPreferences, the four identity cases, the two success-trace cases, and 1600 concurrent writes against the lock.
The locale bug, and why the old test could not have caught it

Date.dateString() built its SimpleDateFormat with Locale.getDefault(), and SimpleDateFormat renders digits in the locale's own numbering system. Reproduced on the JVM against the exact pattern:

locale               ascii
en-US             -> true
und (ROOT)        -> true
fa                -> false
fa-IR             -> false
ar-EG             -> false
ar-EG-u-nu-arab   -> false
hi-IN-u-nu-deva   -> false
ne-NP             -> false

On those devices the "ISO 8601" string came out in non-ASCII digits, which the ingest contract's date parser will not accept — an Embedded Data date field would be stored raw and flagged coercion_failed, for those users only. Locale.ROOT fixes it.

Scope. Only dateString() changed. The other three formatters in that file parse server-sent ISO strings, and DecimalFormat's Character.digit fallback reads ASCII digits under fa and ar-EG alike — verified — so they are not a live bug and were left alone, with a comment on dateString saying why. The one-line fix also covers UserManager's Display(surveyId, lastDisplayedAt.dateString()), which had the same hazard and predates this PR.

The pre-existing assertion was assertEquals(signedUpAt.dateString(), json.get("signedUpAt").asString)dateString() compared to itself, so it passed under any locale. The new test asserts the shape instead, under Locale("fa") and Locale.forLanguageTag("ar-EG-u-nu-arab") (the latter forces the numbering system rather than relying on what ICU picks), restoring the default in a finally. Verified on the JVM that its regex accepts the ROOT output and rejects both native-digit ones, i.e. it genuinely goes red on the old code.

Why the identity tests were rewritten twice

The defect. UserManager.set(userId) only enqueues into the debounced UpdateQueue; UserManager.userId is written when the sync completes. So immediately after Formbricks.setUserId("user-a") that property is still null, and the following setUserId("user-b") took the first-identification branch — where the bag is deliberately kept. The test was asserting an empty bag against a code path that never ran. It also flipped isInitialized by hand instead of setting the SDK up, so no sync could ever have completed anyway.

The first fix stood the SDK up for real (Formbricks.setup + MockFormbricksApiService) and waited 2s for the id to land. Still red — and the runner's log tail is entirely harden-runner network output, so the failing assertion is not reachable through the API from here. Rather than guess a third time, the current shape removes the machinery those tests never needed:

  • Formbricks.setup is gone from this class. It fetched the workspace and ran the legacy-cache migration, writing state that other test classes assert on — a plausible source of cross-class breakage that has nothing to do with what these tests check.
  • The wait is gone. The tests now seed the SharedPreferences key the getter falls back to: exact, no timer, no request, and it models the honest scenario — the app relaunches already identified, then a different user signs in. setUserId then genuinely takes the switch branch, and the same-id case genuinely takes the early return.
  • appUrl/workspaceId are assigned directly in setUp because they are lateinit: a queued update finding them unset would throw on the UpdateQueue's timer thread, and a throwing TimerTask cancels that Timer for the whole process. An @After logs out and resets the queue so nothing this class starts can fire during another one.

The production code is unchanged throughout. The clearing sits inside the SDK's own if (existing != null && existing.isNotEmpty()) branch, so it is exactly as timely as the UserManager.logout() teardown beside it.

Why a non-finite number is refused at the door

Gson writes a bare NaN / Infinity into JsonObject.toString(), which is not valid JSON. The payload it lands in is the whole survey's props blob, parsed in the WebView with JSON.parse at the top of the template with no try/catch around it — so a single setEmbeddedData(mapOf("x" to EmbeddedDataValue.number(Double.NaN))) from host code would mean no survey, not a missing field. The store drops it with a log instead, and a test asserts the snapshot still parses.

Review follow-ups (commit bdeb6fc)
  1. The locale bug above — real, fixed, and the fold explains the scope decision and the JVM verification.
  2. The debug success trace, mirroring feat(js-core): debug-log the Embedded Data bag's successful writes [ENG-1844] formbricks#9091 via Logger.d (already gated on Formbricks.loggingEnabled). The message is built by an internal setTrace rather than inlined, so "keys only, never values" is assertable directly instead of by scraping logcat — the only other option in an instrumented suite, and a flaky one. Built and logged outside synchronized, so a log write never holds the lock.

Breaking changes

None. Three new methods on the Formbricks object, one new public EmbeddedDataValue sealed class, and one new key in a payload the renderer already accepts. setup, track, setUserId, setAttribute(s), setLanguage and logout keep their signatures; logout() and an identity-switching setUserId() additionally clear the new in-memory bag, which did not exist before.

Date.dateString() now formats with Locale.ROOT. Its output is a machine-facing ISO 8601 string that is only ever sent on the wire, and on every locale that was already ASCII the bytes are identical — the change is only visible on the native-digit locales listed above, where the previous output was rejected downstream.

QA / Test Plan

How to test

  • Declare an Embedded Data / hidden field plan on an app survey. Call Formbricks.setEmbeddedData(mapOf("plan" to EmbeddedDataValue.string("pro"))), then Formbricks.track() the survey's action → the response shows plan = pro.
  • Set plan, then set screen in a second call → the response carries both. Merge, not replace.
  • setEmbeddedData(mapOf("plan" to null))plan is absent from the next response, screen is still there.
  • clearEmbeddedData() with no argument → the next response carries none of the fields; clearEmbeddedData("plan") removes only that one.
  • Set the device language to Persian (فارسی) or to Arabic (Egypt), then send a Date value → it arrives on the response as an ISO 8601 string in ASCII digits and reads back as a date on a date-typed field. Before this PR's follow-up commit it arrived in Persian/Arabic digits and was flagged coercion_failed. This is the highest-value step here — it only ever reproduced on those devices.
  • With logging enabled, watch logcat while calling setEmbeddedData / clearEmbeddedData → each call logs the keys it set or removed and what the bag now holds. No values appear in any line. With logging disabled, nothing is logged.
  • Open a survey, call setEmbeddedData while it is on screen, finish it → the response records the value from when the survey appeared.
  • Same with a survey that has a delay: set the value during the delay → the response carries the value as of when it actually appeared.
  • Send a key the survey does not declare → the response is created normally without it, and the WebView console logs that the key was dropped. Nothing throws.
  • setUserId("a")wait for the sync to land → set values → setUserId("b") → survey → the response carries none of them. Waiting matters: identity is debounced, so a back-to-back switch is not yet a switch to the SDK.
  • logout() after setting values → the next response carries none of them.
  • Kill and relaunch the app without re-pushing → the next response carries nothing. The bag is memory-only by design.
  • EmbeddedDataValue.number(Double.NaN) from host code → logged and skipped, and the survey still renders. This is the one that would fail loudly if the guard were missing.
  • A value containing " or </script> → survives to the response intact and does not break the WebView (the payload is base64-encoded, so this should be unaffected — confirm).

Preconditions / test data

  • An app survey with at least one ingested field (a hidden field works), the SDK pointed at an instance serving the current surveys.umd.cjs, and a host app. Response card is the readout.
  • For the locale step: a device or emulator whose system language can be set to Persian or Arabic (Egypt).

Risks & regressions

  • Identity clearing is only as timely as the SDK's own identity teardown. Both hang off UserManager.userId, which lands after the debounced /user sync — so a setUserId("a"); setUserId("b") in the same tick clears neither the user state nor the bag. Pre-existing behaviour, not new here, but it is what to expect when testing.
  • logout() and identity-switching setUserId() now also clear the bag. Nothing else observes it, so no other behaviour changes.
  • Date.dateString() is shared with UserManager's display records. The locale fix changes their bytes only on native-digit locales, where they were already wrong; worth a glance at display-based targeting on such a device.
  • The props payload grows one key. It is base64-encoded before being embedded in the HTML, so host-supplied text cannot break out of the script — re-check with a value containing </script>.
  • The new trace prints field names to logcat when logging is enabled. Intended and matching js-core; the no-values rule is what keeps it safe, and it is asserted rather than assumed.

Migrations / env / cutover

  • none. Ships as a normal Maven Central release; no server or config change.

Generated by Claude Code

A host app can attach context to future responses without tying it to a
trigger. `track()` takes a name and nothing else, so today the only way to
get context onto a response is to declare it in the survey and have the
respondent type it.

    Formbricks.setEmbeddedData(mapOf(
        "screen" to EmbeddedDataValue.string("checkout"),
        "plan" to EmbeddedDataValue.string("pro"),
    ))
    Formbricks.setEmbeddedData(mapOf("screen" to null))  // remove one key
    Formbricks.clearEmbeddedData("plan")                 // same, explicitly
    Formbricks.clearEmbeddedData()                       // everything

Merge, never replace, so refreshing a volatile field cannot wipe a stable
one. `null` removes a key; a key left out is untouched, which is how a host
skips a field it has no value for this screen. The single-key and
clear-everything forms are separate overloads, so a non-null `String`
parameter means a host reading the key from its own state cannot
accidentally wipe the bag.

In-memory and never persisted: persisting would blur the Embedded Data ↔
contact-attribute boundary and create a PII-at-rest surface. Cleared on an
identity switch and on logout so one user's context cannot ride onto the
next user's responses on a shared device; kept on first identification,
because a host legitimately pushes context before it knows who the user is.

Callable before setup(), unlike every other public method: a host that
pushes context at launch must not have the value dropped because
initialization had not finished.

Snapshotted in loadHtml(), which runs when the survey is actually presented
after any configured delay, and frozen for its lifetime. The bag rides the
props payload that already exists, under `hiddenFieldsRecord` — no new
bridge message, and deliberately so: a setEmbeddedData after display must
not reach the survey on screen. It is passed raw and unfiltered, because the
ingest contract lives in the renderer and the server re-runs all of it.

A non-finite number is logged and skipped: it would serialize as a bare NaN
or Infinity, which is not valid JSON, so JSON.parse in the WebView would
throw and no survey would render at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 90697b5f-5aa8-433f-bb58-bd8e5cca9777


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@CLAassistant

CLAassistant commented Aug 28, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

itsjavi and others added 2 commits August 28, 2026 14:05
…e bag [ENG-2472]

CI caught what no local check could: `UserManager.set(userId)` only enqueues
into the debounced UpdateQueue, so `UserManager.userId` is still null
immediately after `Formbricks.setUserId`. The switch test therefore took the
first-identification branch, where the bag is kept on purpose — asserting an
empty bag against a code path that never ran. It also flipped `isInitialized`
by hand instead of setting the SDK up, so no sync could ever complete.

The production code is right and stays as it is: the clearing sits inside the
SDK's own "a different userId is set" branch, so it is exactly as timely as the
`UserManager.logout()` teardown beside it. The tests were asserting a state the
SDK cannot reach that fast.

The identity cases now run against a real `Formbricks.setup` with the mock API
service and wait for the id to land — the same pattern as the SDK's own identity
tests — so the switch and same-id cases exercise the branches they name.

Also wraps the shared_prefs probe's file read, so an unreadable file fails the
read rather than the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz
…2472]

Second red run on the emulator job, and the runner's log tail is entirely
harden-runner output, so the failing assertion is not reachable from here.
Rather than guess a third time, this removes the machinery the identity tests
did not need.

`Formbricks.setup` is gone from this class: it fetched the workspace and ran
the legacy-cache migration, writing state that other test classes assert on,
and none of it is needed to exercise an identity switch. The debounced wait is
gone too — identity lands only when a network sync completes, so waiting tests
the UpdateQueue's timing as much as the branch it names.

Seeding the SharedPreferences key the getter falls back to is exact: no timer,
no request, and it models the honest scenario — the app relaunches already
identified, then a different user signs in. `setUserId` then genuinely takes
the switch branch, and the same-id case genuinely takes the early return.

`appUrl`/`workspaceId` are assigned directly in setUp because they are lateinit:
a queued update that found them unset would throw on the UpdateQueue's timer
thread, and a throwing TimerTask cancels that Timer for the whole process — the
same hazard the SDK's own comments call out. An @after now logs out and resets
the queue so nothing this class starts can fire during another one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz
@itsjavi
itsjavi requested a review from pandeymangg August 28, 2026 15:12
@pandeymangg

Copy link
Copy Markdown
Contributor

Two things from manual testing + review:

  1. Debug success tracesetEmbeddedData succeeds silently and the bag is invisible (memory-only, no getter), so a dev gets no confirmation until a survey displays. js-core just added a debug-level trace in feat(js-core): debug-log the Embedded Data bag's successful writes [ENG-1844] formbricks#9091 (keys set/removed + what the bag holds — keys only, never values). Logger.d is already gated on loggingEnabled, so it's a small mirror.

  2. DateValue serialization is locale-sensitiveDate.dateString() builds SimpleDateFormat(pattern, Locale.getDefault()), so on native-digit locales (fa, some ar variants) the "ISO 8601" string comes out in non-ASCII digits, which the ingest contract's date parser won't accept — the value would be stored raw and flagged coercion_failed for those users only. Locale.ROOT (or Locale.US) in the formatter fixes it; worth a test with Locale.setDefault(Locale("fa")) around the assertion.

…NG-2472]

Two review findings.

1. `Date.dateString()` built its SimpleDateFormat with Locale.getDefault(), and
   SimpleDateFormat renders digits in the locale's own numbering system. On a
   device set to fa, fa-IR, ar-EG, hi-IN-u-nu-deva or ne-NP the "ISO 8601"
   string came out in non-ASCII digits, which the ingest contract's date parser
   will not accept - the value would be stored raw and flagged coercion_failed,
   for those users only. Locale.ROOT fixes it. Verified on the JVM: six locales
   produce non-ASCII digits with getDefault() and ASCII with ROOT, and the new
   test's regex accepts the ROOT output while rejecting both native-digit ones.

   Only the formatting direction needed it. The three parsers in that file keep
   Locale.getDefault(): checked, and DecimalFormat's Character.digit fallback
   reads the server's ASCII digits under fa and ar-EG alike, so they are not a
   live bug and are left alone.

   The pre-existing date assertion compared dateString() against dateString(),
   so it could not have caught this. The new test asserts the shape instead.

2. setEmbeddedData succeeded in silence. Mirrors the js-core debug trace from
   formbricks/formbricks#9091: keys set and removed, what the bag now holds,
   and the sentence that pre-empts the next question. Keys only, never values -
   the documented use of this bag includes hashed identity fields - and the
   message is built by an internal `setTrace` so that property is directly
   assertable rather than scraped from logcat. Built and logged outside the
   lock, so a log write never holds it.

:android:compileDebugKotlin and :android:compileDebugAndroidTestKotlin both
clean. The instrumented suite still runs only in CI; no emulator here.

itsjavi commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Both done in bdeb6fc. The locale one is a real bug and a good catch — thank you.

2. DateValue serialization was locale-sensitive. Confirmed and fixed. Reproduced on the JVM against the exact pattern Date.dateString() uses:

locale               formatted                       ascii
en-US             -> 2025-08-31T10:40:00.000Z        true
und (ROOT)        -> 2025-08-31T10:40:00.000Z        true
fa                -> ۲۰۲۵-…                          false
fa-IR             -> …                               false
ar-EG             -> …                               false
ar-EG-u-nu-arab   -> …                               false
hi-IN-u-nu-deva   -> …                               false
ne-NP             -> …                               false

Locale.setDefault(fa):
  getDefault() formatter -> non-ASCII
  ROOT formatter         -> 2025-08-31T10:40:00.000Z

So six locales, not just fa. Locale.ROOT it is.

Scope, since DateExtensions.kt has four formatters: I changed only dateString(). I checked the other three before deciding — they parse server-sent ISO strings, and DecimalFormat falls back to Character.digit, so ASCII digits parse fine under fa and ar-EG alike:

fa       parse("2026-08-31T10:00:00.000Z") -> Mon Aug 31 10:00:00 UTC 2026
ar-EG    parse("2026-08-31T10:00:00.000Z") -> Mon Aug 31 10:00:00 UTC 2026

Not a live bug, so I left them and said so in a comment on dateString rather than widening the diff.

Worth flagging: the fix also touches UserManager's Display(surveyId, lastDisplayedAt.dateString()), which had the same hazard on the same locales and predates this PR. Same one-line change covers it.

The existing test could not have caught thisassertEquals(signedUpAt.dateString(), json.get("signedUpAt").asString) compares dateString() to itself. The new test asserts the shape instead, under Locale("fa") and Locale.forLanguageTag("ar-EG-u-nu-arab") (the latter forces the numbering system, so it does not depend on what ICU picks for a bare locale), with the default restored in a finally. Verified on the JVM that the test's regex accepts the ROOT output and rejects both native-digit ones — i.e. it genuinely goes red on the old code.

1. Debug trace. Mirrors formbricks/formbricks#9091 via Logger.d. The message is built by an internal setTrace rather than inlined, so "keys only, never values" is assertable directly instead of by scraping logcat — which is the only other option in an instrumented suite and is exactly the kind of flaky I would rather not add to this file. Built and logged outside synchronized, so a log write never holds the lock.

:android:compileDebugKotlin and :android:compileDebugAndroidTestKotlin both clean. The instrumented suite still only runs in CI — no emulator and no /dev/kvm here — so the locale behaviour and the test's regex were both verified on the JVM instead, as above.


Generated by Claude Code

@sonarqubecloud

Copy link
Copy Markdown

@pandeymangg
pandeymangg added this pull request to the merge queue Aug 31, 2026
Merged via the queue into main with commit bb3d1ae Aug 31, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants